mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 04:14:02 +00:00
Fly the camera through the renderer's camera port
The Cesium camera port moves into render/cesium/camera.js, with the listener list and position conversion shared from common.js. It now covers flights (flyTo, flyToBounds, cancelFlight, flying), lookAt and release, command hooks, and a flightEnd event for the flights it starts. A flight's end that Cesium raises inside one of the port's own calls is delivered once Cesium's call returns, so its callback sees the flight let go of. A flight Cesium declines to start, mid scene-mode morph, ends as cancelled instead of never settling. Flight traces run in Node on the recorder's headless camera with a virtual clock; the browser harness keeps the instant traces. The startup fly-in, landmark and viewport flights, share-link restore and the shell's camera state now go through the port. camera.js and sharelink.js leave the Cesium allow-list.
This commit is contained in:
1 parent
de0ff6cd2d
commit
f95324acb1
20 files changed
+846
-305
No files matched your search
@@ -968,6 +968,7 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/ui/localePicker.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
@@ -2492,6 +2493,8 @@
|
||||
"src/render/cesium/appRenderer.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/render/cesium/ports.js",
|
||||
"src/render/cesium/common.js",
|
||||
"src/render/cesium/camera.js",
|
||||
"src/render/camera/events.js",
|
||||
"src/render/contract/index.js",
|
||||
"src/sources/sourceSlot.js",
|
||||
@@ -3800,6 +3803,7 @@
|
||||
"src/layers/vessels/testing.js",
|
||||
"src/layers/vessels/tracking.js",
|
||||
"src/locations.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/cityPois.js",
|
||||
"src/navigationPolicy.js",
|
||||
"src/overlays/worldOverlay.js",
|
||||
@@ -4096,6 +4100,7 @@
|
||||
"src/keylessGeocoder.js",
|
||||
"src/layers/firms/anchors.js",
|
||||
"src/locations.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/cityPois.js",
|
||||
"src/cameraGroundGuard.js",
|
||||
"src/search/alpsGeocoder.js",
|
||||
|
||||
@@ -1,25 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { flyToAustin } from '../camera.js';
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
|
||||
test('teardown before the initial camera delay prevents a late flight', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let flights = 0;
|
||||
let cancelled = 0;
|
||||
const stop = flyToAustin({
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
setView() {},
|
||||
flyTo() {
|
||||
flights++;
|
||||
const stop = flyToAustin(
|
||||
bindFakeCesiumCamera({
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
setView() {},
|
||||
flyTo() {
|
||||
flights++;
|
||||
},
|
||||
cancelFlight() {
|
||||
cancelled++;
|
||||
},
|
||||
},
|
||||
cancelFlight() {
|
||||
cancelled++;
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
stop();
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(flights, 0);
|
||||
assert.equal(cancelled, 1);
|
||||
assert.equal(cancelled, 0, 'no fly-in started, so none to cancel');
|
||||
});
|
||||
|
||||
test('teardown cancels the fly-in, not a flight the user started since', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const cancelled = [];
|
||||
let current = null;
|
||||
const viewer = bindFakeCesiumCamera({
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
get _currentFlight() {
|
||||
return current;
|
||||
},
|
||||
setView() {},
|
||||
flyTo(options) {
|
||||
current?.cancel();
|
||||
current = {
|
||||
name: options.duration === 4 ? 'fly-in' : 'user',
|
||||
...options,
|
||||
};
|
||||
},
|
||||
cancelFlight() {
|
||||
cancelled.push(current?.name);
|
||||
const flight = current;
|
||||
current = null;
|
||||
flight?.cancel();
|
||||
},
|
||||
},
|
||||
});
|
||||
const stop = flyToAustin(viewer);
|
||||
t.mock.timers.tick(500);
|
||||
stop();
|
||||
assert.deepEqual(cancelled, ['fly-in']);
|
||||
|
||||
// A user flight replaces the fly-in; teardown leaves it alone.
|
||||
const second = flyToAustin(viewer);
|
||||
t.mock.timers.tick(500);
|
||||
viewer.camera.flyTo({ duration: 2 });
|
||||
second();
|
||||
assert.deepEqual(cancelled, ['fly-in']);
|
||||
assert.equal(current.name, 'user');
|
||||
});
|
||||
+16
-18
@@ -1,36 +1,34 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { toRadians } from './geo/index.js';
|
||||
import { cameraOf } from './render/rendererOf.js';
|
||||
|
||||
/**
|
||||
* Set camera to Austin on load with a cinematic fly-in.
|
||||
* @returns {Function} Cancels the pending or active startup flight.
|
||||
*/
|
||||
export function flyToAustin(viewer) {
|
||||
const camera = cameraOf(viewer);
|
||||
// Start from a high altitude, then fly down
|
||||
viewer.camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 25000),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(0),
|
||||
pitch: Cesium.Math.toRadians(-90),
|
||||
roll: 0.0,
|
||||
},
|
||||
camera.setView({
|
||||
destination: { lon: -97.7431, lat: 30.2672, height: 25000 },
|
||||
orientation: { heading: 0, pitch: toRadians(-90), roll: 0 },
|
||||
});
|
||||
|
||||
// Cinematic fly-in after a brief pause
|
||||
let flight = null;
|
||||
const timer = setTimeout(() => {
|
||||
if (viewer.isDestroyed()) return;
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 600),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(15),
|
||||
pitch: Cesium.Math.toRadians(-30),
|
||||
roll: 0.0,
|
||||
flight = camera.flyTo(
|
||||
{ lon: -97.7431, lat: 30.2672, height: 600 },
|
||||
{
|
||||
orientation: { heading: toRadians(15), pitch: toRadians(-30), roll: 0 },
|
||||
durationS: 4.0,
|
||||
easing: 'cubicInOut',
|
||||
},
|
||||
duration: 4.0,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
);
|
||||
}, 500);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (!viewer.isDestroyed()) viewer.camera.cancelFlight();
|
||||
// Only the fly-in: a flight the user started since is theirs.
|
||||
if (!viewer.isDestroyed()) flight?.cancel();
|
||||
};
|
||||
}
|
||||
+60
-61
@@ -13,6 +13,7 @@ import { cancelCameraArrival } from './data/cameraArrival.js';
|
||||
import { guardCameraAboveGround } from './cameraGroundGuard.js';
|
||||
import { CITY_POIS } from './cityPois.js';
|
||||
import { heightsFor } from './queries/heights.js';
|
||||
import { cameraOf } from './render/rendererOf.js';
|
||||
|
||||
/** Newest landmark arrival; an older post-arrival ground guard yields to it. */
|
||||
let arrivalGeneration = 0;
|
||||
@@ -37,30 +38,23 @@ export const GLOBE_VIEW = Object.freeze({
|
||||
* @returns {{latitude: number, longitude: number, heightM: number}}
|
||||
*/
|
||||
export function flyToGlobeView(viewer, options = {}) {
|
||||
const carto = viewer.camera.positionCartographic;
|
||||
const longitude = Cesium.Math.toDegrees(carto.longitude);
|
||||
const latitude = Cesium.Math.toDegrees(carto.latitude);
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
longitude,
|
||||
latitude,
|
||||
GLOBE_VIEW.heightM,
|
||||
),
|
||||
orientation: {
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
|
||||
roll: 0,
|
||||
const camera = cameraOf(viewer);
|
||||
const { lon: longitude, lat: latitude } = camera.pose();
|
||||
camera.cancelFlight();
|
||||
camera.flyTo(
|
||||
{ lon: longitude, lat: latitude, height: GLOBE_VIEW.heightM },
|
||||
{
|
||||
orientation: {
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
|
||||
roll: 0,
|
||||
},
|
||||
durationS: finitePositive(options.duration) || GLOBE_VIEW.durationS,
|
||||
endFrame: 'world',
|
||||
onComplete: options.onComplete,
|
||||
onCancel: options.onCancel,
|
||||
},
|
||||
duration: finitePositive(options.duration) || GLOBE_VIEW.durationS,
|
||||
endTransform: Cesium.Matrix4.IDENTITY,
|
||||
// Cesium's Camera.flyTo reads `complete`/`cancel`. `onComplete`/`onCancel`
|
||||
// are this module's OWN option names and are silently ignored by Cesium —
|
||||
// spelling them through to flyTo meant the reset never resolved on the
|
||||
// flight's own events and every caller fell back to its watchdog timeout.
|
||||
complete: options.onComplete,
|
||||
cancel: options.onCancel,
|
||||
});
|
||||
);
|
||||
return { latitude, longitude, heightM: GLOBE_VIEW.heightM };
|
||||
}
|
||||
|
||||
@@ -147,11 +141,11 @@ export function flyToLandmark(viewer, lat, lon, options = {}) {
|
||||
)
|
||||
: range;
|
||||
|
||||
const hpr = new Cesium.HeadingPitchRange(
|
||||
Cesium.Math.toRadians(heading),
|
||||
Cesium.Math.toRadians(pitch),
|
||||
framingRange,
|
||||
);
|
||||
const hpr = {
|
||||
heading: Cesium.Math.toRadians(heading),
|
||||
pitch: Cesium.Math.toRadians(pitch),
|
||||
range: framingRange,
|
||||
};
|
||||
|
||||
if (typeof onStart === 'function') {
|
||||
try {
|
||||
@@ -162,14 +156,15 @@ export function flyToLandmark(viewer, lat, lon, options = {}) {
|
||||
}
|
||||
|
||||
// Fly to target, then lock with lookAt for guaranteed centering
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(targetPosition, boundingRadius),
|
||||
const camera = cameraOf(viewer);
|
||||
camera.flyToBounds(
|
||||
{ center: targetPosition, radius: boundingRadius },
|
||||
{
|
||||
offset: hpr,
|
||||
duration,
|
||||
complete: () => {
|
||||
viewer.camera.lookAt(targetPosition, hpr);
|
||||
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
durationS: duration,
|
||||
onComplete: () => {
|
||||
camera.lookAt(targetPosition, hpr);
|
||||
camera.release();
|
||||
// Framing used a predicted ground height. Once tiles stream in, measure
|
||||
// the rendered surface and lift the eye if it is buried or pressed
|
||||
// against it; a newer arrival or a manual gesture ends the check.
|
||||
@@ -190,7 +185,7 @@ export function flyToLandmark(viewer, lat, lon, options = {}) {
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel: () => {
|
||||
onCancel: () => {
|
||||
if (typeof onCancel === 'function') {
|
||||
try {
|
||||
onCancel();
|
||||
@@ -788,15 +783,17 @@ function flyToViewportBounds(viewer, viewport, options = {}) {
|
||||
const paddedLonSpan = metrics.lonSpanDeg + longitudePadding * 2;
|
||||
const south = Math.max(-89.9, southwest.lat - latitudePadding);
|
||||
const north = Math.min(89.9, northeast.lat + latitudePadding);
|
||||
const rectangle =
|
||||
const rect =
|
||||
paddedLonSpan >= 360
|
||||
? Cesium.Rectangle.fromDegrees(-180, south, 180, north)
|
||||
: Cesium.Rectangle.fromDegrees(
|
||||
wrapLongitude(southwest.lng - longitudePadding),
|
||||
? { west: -180, south, east: 180, north }
|
||||
: {
|
||||
west: wrapLongitude(southwest.lng - longitudePadding),
|
||||
south,
|
||||
wrapLongitude(southwest.lng + metrics.lonSpanDeg + longitudePadding),
|
||||
east: wrapLongitude(
|
||||
southwest.lng + metrics.lonSpanDeg + longitudePadding,
|
||||
),
|
||||
north,
|
||||
);
|
||||
};
|
||||
if (typeof beforeFly === 'function' && beforeFly() === false)
|
||||
return CANCELLED_SEARCH;
|
||||
if (typeof onStart === 'function') {
|
||||
@@ -806,29 +803,31 @@ function flyToViewportBounds(viewer, viewport, options = {}) {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
viewer.camera.flyTo({
|
||||
destination: rectangle,
|
||||
duration,
|
||||
endTransform: Cesium.Matrix4.IDENTITY,
|
||||
complete: () => {
|
||||
if (typeof onComplete === 'function') {
|
||||
try {
|
||||
onComplete();
|
||||
} catch {
|
||||
/* no-op */
|
||||
cameraOf(viewer).flyTo(
|
||||
{ rect },
|
||||
{
|
||||
durationS: duration,
|
||||
endFrame: 'world',
|
||||
onComplete: () => {
|
||||
if (typeof onComplete === 'function') {
|
||||
try {
|
||||
onComplete();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel: () => {
|
||||
if (typeof onCancel === 'function') {
|
||||
try {
|
||||
onCancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
},
|
||||
onCancel: () => {
|
||||
if (typeof onCancel === 'function') {
|
||||
try {
|
||||
onCancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
// Same short-way-round rule for the reported centre: averaging raw longitudes
|
||||
// puts a dateline-crossing box's centre on the opposite side of the planet.
|
||||
return {
|
||||
|
||||
+26
-24
@@ -21,10 +21,11 @@ import {
|
||||
GLOBE_VIEW,
|
||||
searchAndFlyTo,
|
||||
} from './locations.js';
|
||||
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
|
||||
|
||||
function stubViewer() {
|
||||
const flights = [];
|
||||
return {
|
||||
return bindFakeCesiumCamera({
|
||||
flights,
|
||||
scene: { globe: null, canvas: { clientWidth: 0, clientHeight: 0 } },
|
||||
camera: {
|
||||
@@ -39,7 +40,7 @@ function stubViewer() {
|
||||
lookAt() {},
|
||||
lookAtTransform() {},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const AUSTIN_RESULT = {
|
||||
@@ -604,29 +605,22 @@ test('GLOBE_VIEW preset height sits in the global view band', () => {
|
||||
assert.equal(GLOBE_VIEW.pitchDeg, -90);
|
||||
});
|
||||
|
||||
// The globe flight's callbacks are what resolves Reset Globe / zoom_to_globe.
|
||||
// They were published under this module's OWN option names (`onComplete` /
|
||||
// `onCancel`), which Cesium's Camera.flyTo ignores — so neither ever fired and
|
||||
// every caller resolved off its ~4.2 s watchdog timeout instead of the flight.
|
||||
test('the globe flight publishes its callbacks under Cesium\'s own option names', () => {
|
||||
// The globe flight's callbacks are what resolves Reset Globe / zoom_to_globe:
|
||||
// each must reach the caller when the flight ends that way.
|
||||
test('the globe flight ends through its own callbacks', () => {
|
||||
const viewer = stubViewer();
|
||||
const fired = [];
|
||||
flyToGlobeView(viewer, {
|
||||
const hooks = {
|
||||
onComplete: () => fired.push('complete'),
|
||||
onCancel: () => fired.push('cancel'),
|
||||
});
|
||||
|
||||
const flight = viewer.flights[0];
|
||||
assert.equal(typeof flight.complete, 'function', 'Cesium resolves arrival through `complete`');
|
||||
assert.equal(typeof flight.cancel, 'function', 'Cesium reports supersession through `cancel`');
|
||||
// Not merely present under both spellings: the ignored names must be gone,
|
||||
// or a later reader can "fix" the wrong one back.
|
||||
assert.equal('onComplete' in flight, false, 'Cesium ignores onComplete — do not publish it');
|
||||
assert.equal('onCancel' in flight, false, 'Cesium ignores onCancel — do not publish it');
|
||||
|
||||
flight.complete();
|
||||
flight.cancel();
|
||||
};
|
||||
flyToGlobeView(viewer, hooks);
|
||||
flyToGlobeView(viewer, hooks);
|
||||
viewer.flights[0].complete();
|
||||
viewer.flights[1].cancel();
|
||||
assert.deepEqual(fired, ['complete', 'cancel'], 'each hook reaches the caller it belongs to');
|
||||
viewer.flights[0].cancel();
|
||||
assert.deepEqual(fired, ['complete', 'cancel'], 'a flight ends once');
|
||||
});
|
||||
|
||||
test('a globe flight without callbacks still flies (both hooks are optional)', () => {
|
||||
@@ -634,8 +628,7 @@ test('a globe flight without callbacks still flies (both hooks are optional)', (
|
||||
const target = flyToGlobeView(viewer);
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
assert.equal(target.heightM, GLOBE_VIEW.heightM);
|
||||
assert.equal(viewer.flights[0].complete, undefined);
|
||||
assert.equal(viewer.flights[0].cancel, undefined);
|
||||
viewer.flights[0].complete();
|
||||
});
|
||||
|
||||
test('geocoded Location branches forward the resolved-navigation ownership hook', () => {
|
||||
@@ -674,8 +667,13 @@ test('city and landmark flights expose completion and cancellation hooks', () =>
|
||||
onComplete: () => overviewEvents.push('complete'),
|
||||
onCancel: () => overviewEvents.push('cancel'),
|
||||
});
|
||||
flyToPresetLocation(overviewViewer, 'austin', {
|
||||
viewMode: 'overview',
|
||||
onComplete: () => overviewEvents.push('complete'),
|
||||
onCancel: () => overviewEvents.push('cancel'),
|
||||
});
|
||||
overviewViewer.flights[0].complete();
|
||||
overviewViewer.flights[0].cancel();
|
||||
overviewViewer.flights[1].cancel();
|
||||
assert.deepEqual(overviewEvents, ['complete', 'cancel']);
|
||||
|
||||
const landmarkViewer = stubViewer();
|
||||
@@ -684,8 +682,12 @@ test('city and landmark flights expose completion and cancellation hooks', () =>
|
||||
onComplete: () => landmarkEvents.push('complete'),
|
||||
onCancel: () => landmarkEvents.push('cancel'),
|
||||
});
|
||||
flyToPresetLocation(landmarkViewer, 'austin', {
|
||||
onComplete: () => landmarkEvents.push('complete'),
|
||||
onCancel: () => landmarkEvents.push('cancel'),
|
||||
});
|
||||
landmarkViewer.flights[0].complete();
|
||||
landmarkViewer.flights[0].cancel();
|
||||
landmarkViewer.flights[1].cancel();
|
||||
assert.deepEqual(landmarkEvents, ['complete', 'cancel']);
|
||||
});
|
||||
|
||||
|
||||
@@ -318,7 +318,6 @@ test('the application refuses a renderer it cannot run on, and destroys it', asy
|
||||
});
|
||||
|
||||
const EXPECTED_MISSING = [
|
||||
'camera.cancelFlight',
|
||||
'camera.controls',
|
||||
'camera.controls.coast',
|
||||
'camera.controls.coast.drag',
|
||||
@@ -334,9 +333,6 @@ const EXPECTED_MISSING = [
|
||||
'camera.controls.suspend',
|
||||
'camera.controls.zoomFactor',
|
||||
'camera.controls.zoomInputs',
|
||||
'camera.flyTo',
|
||||
'camera.flyToBounds',
|
||||
'camera.flying',
|
||||
'camera.follow',
|
||||
'camera.followFramed',
|
||||
'camera.followOffset',
|
||||
@@ -344,11 +340,8 @@ const EXPECTED_MISSING = [
|
||||
'camera.followOffset.set',
|
||||
'camera.followed',
|
||||
'camera.look',
|
||||
'camera.lookAt',
|
||||
'camera.matrices',
|
||||
'camera.move',
|
||||
'camera.onCommand',
|
||||
'camera.release',
|
||||
'camera.rotateAround',
|
||||
'camera.setFov',
|
||||
'camera.zoom',
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* @module render/cesium/camera
|
||||
* @description The Cesium renderer's camera port over a Cesium camera:
|
||||
* poses, views, flights, `lookAt`, events and command hooks, each mapped
|
||||
* onto Cesium's own camera call.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { createCameraEvents } from '../camera/events.js';
|
||||
import { listenerList, toCartesian } from './common.js';
|
||||
import { cameraFlightActive } from './internals.js';
|
||||
|
||||
/** The contract's easings as Cesium's. */
|
||||
export const EASINGS = Object.freeze({
|
||||
linear: Cesium.EasingFunction.LINEAR_NONE,
|
||||
quadraticInOut: Cesium.EasingFunction.QUADRATIC_IN_OUT,
|
||||
cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
cubicOut: Cesium.EasingFunction.CUBIC_OUT,
|
||||
quinticInOut: Cesium.EasingFunction.QUINTIC_IN_OUT,
|
||||
});
|
||||
|
||||
/** A contract destination as Cesium's: a Cartesian3, or a Rectangle. */
|
||||
export function toDestination(d) {
|
||||
if ('rect' in d) {
|
||||
const r = d.rect;
|
||||
return Cesium.Rectangle.fromDegrees(r.west, r.south, r.east, r.north);
|
||||
}
|
||||
return toCartesian(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* A contract orientation as Cesium's; omitted angles are heading 0, pitch
|
||||
* straight down and roll 0, as the contract says.
|
||||
*/
|
||||
export function toOrientation(o = {}) {
|
||||
if ('direction' in o)
|
||||
return { direction: toCartesian(o.direction), up: toCartesian(o.up) };
|
||||
return {
|
||||
heading: o.heading ?? 0,
|
||||
pitch: o.pitch ?? -Math.PI / 2,
|
||||
roll: o.roll ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{scene: object, camera: object, report: (error: unknown) => void}}
|
||||
* options `scene` gives `requestRender` and the `postUpdate` tick
|
||||
* `changed` is measured on.
|
||||
* @returns {{port: import('../contract/camera').CameraPort, dispose: () => void}}
|
||||
*/
|
||||
export function createCesiumCamera({ scene, camera, report }) {
|
||||
const removers = [];
|
||||
// Camera events: Cesium's own moveStart and moveEnd (500 ms after the
|
||||
// last change, never while following), and `changed` measured per
|
||||
// listener on every tick, where Cesium measures its single threshold.
|
||||
const cameraEvents = createCameraEvents({
|
||||
pose: () => cameraPort.pose(),
|
||||
report,
|
||||
});
|
||||
removers.push(
|
||||
scene.postUpdate.addEventListener(() => cameraEvents.stepChanged()),
|
||||
camera.moveStart.addEventListener(() => cameraEvents.emit('moveStart')),
|
||||
camera.moveEnd.addEventListener(() => cameraEvents.emit('moveEnd')),
|
||||
);
|
||||
|
||||
// Camera commands: hooks run before each one, so an owner can yield.
|
||||
const commandHooks = listenerList(report);
|
||||
const command = (kind) => commandHooks.fire({ kind });
|
||||
|
||||
// The flight this port started, while it is in progress. Cesium cancels
|
||||
// a flight in progress when another starts, including one started on
|
||||
// its camera directly, and runs the callbacks inside the call or frame
|
||||
// that ends it. `flying` sees every Cesium flight; `flightEnd` comes for
|
||||
// the flights this port started (the application's flights all move onto
|
||||
// the port with the camera family).
|
||||
let flight = null;
|
||||
|
||||
// A flight's end that Cesium raises inside one of this port's own calls
|
||||
// waits until Cesium's call returns, still inside the port's call: Cesium
|
||||
// runs a cancelled flight's callback before it lets go of that flight, so
|
||||
// the callback would read `flying` wrong, and a flight it started would
|
||||
// be let go of with the old one.
|
||||
let inCesiumCall = 0;
|
||||
const pendingEnds = [];
|
||||
function cesiumCall(fn) {
|
||||
inCesiumCall += 1;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
inCesiumCall -= 1;
|
||||
if (inCesiumCall === 0)
|
||||
for (const deliver of pendingEnds.splice(0)) deliver();
|
||||
}
|
||||
}
|
||||
|
||||
function startFlight(start, options) {
|
||||
let resolve;
|
||||
const done = new Promise((r) => (resolve = r));
|
||||
let ended = false;
|
||||
const handle = {
|
||||
done,
|
||||
cancel() {
|
||||
if (flight === handle) cesiumCall(() => camera.cancelFlight());
|
||||
},
|
||||
};
|
||||
const end = (outcome) => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
if (flight === handle) flight = null;
|
||||
const deliver = () => {
|
||||
const cb =
|
||||
outcome === 'complete' ? options.onComplete : options.onCancel;
|
||||
try {
|
||||
cb?.();
|
||||
} catch (error) {
|
||||
report(error);
|
||||
}
|
||||
resolve(outcome);
|
||||
cameraEvents.emit('flightEnd');
|
||||
};
|
||||
if (inCesiumCall) pendingEnds.push(deliver);
|
||||
else deliver();
|
||||
};
|
||||
flight = handle;
|
||||
cesiumCall(() =>
|
||||
start({
|
||||
complete: () => end('complete'),
|
||||
cancel: () => end('cancelled'),
|
||||
}),
|
||||
);
|
||||
// Cesium starts nothing mid scene-mode morph, and calls neither back.
|
||||
if (!ended && !cameraFlightActive(camera)) end('cancelled');
|
||||
scene.requestRender();
|
||||
return handle;
|
||||
}
|
||||
|
||||
const cameraPort = {
|
||||
on: (event, cb, options) => cameraEvents.on(event, cb, options),
|
||||
onCommand: (cb) => commandHooks.on(cb),
|
||||
setView(spec) {
|
||||
command('setView');
|
||||
const d = spec.destination;
|
||||
camera.setView({
|
||||
...(d ? { destination: toDestination(d) } : {}),
|
||||
orientation: toOrientation(spec.orientation),
|
||||
});
|
||||
scene.requestRender();
|
||||
},
|
||||
flyTo(destination, options = {}) {
|
||||
command('flyTo');
|
||||
return startFlight(
|
||||
(callbacks) =>
|
||||
camera.flyTo({
|
||||
destination: toDestination(destination),
|
||||
orientation: toOrientation(options.orientation),
|
||||
duration: options.durationS,
|
||||
easingFunction: options.easing && EASINGS[options.easing],
|
||||
maximumHeight: options.maxHeight,
|
||||
pitchAdjustHeight: options.pitchAdjustHeight,
|
||||
endTransform:
|
||||
options.endFrame === 'world'
|
||||
? Cesium.Matrix4.IDENTITY
|
||||
: undefined,
|
||||
...callbacks,
|
||||
}),
|
||||
options,
|
||||
);
|
||||
},
|
||||
flyToBounds(sphere, options = {}) {
|
||||
command('flyToBounds');
|
||||
const o = options.offset ?? {};
|
||||
return startFlight(
|
||||
(callbacks) =>
|
||||
camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
toCartesian(sphere.center),
|
||||
sphere.radius,
|
||||
),
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
o.heading ?? 0,
|
||||
o.pitch ?? -Math.PI / 4,
|
||||
o.range ?? 0,
|
||||
),
|
||||
duration: options.durationS,
|
||||
...callbacks,
|
||||
},
|
||||
),
|
||||
options,
|
||||
);
|
||||
},
|
||||
cancelFlight() {
|
||||
command('cancelFlight');
|
||||
cesiumCall(() => camera.cancelFlight());
|
||||
},
|
||||
get flying() {
|
||||
return cameraFlightActive(camera);
|
||||
},
|
||||
lookAt(target, offset) {
|
||||
command('lookAt');
|
||||
camera.lookAt(
|
||||
toCartesian(target),
|
||||
new Cesium.HeadingPitchRange(
|
||||
offset.heading,
|
||||
offset.pitch,
|
||||
offset.range,
|
||||
),
|
||||
);
|
||||
scene.requestRender();
|
||||
},
|
||||
release() {
|
||||
command('release');
|
||||
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
scene.requestRender();
|
||||
},
|
||||
viewRectangle() {
|
||||
const rect = camera.computeViewRectangle(Cesium.Ellipsoid.WGS84);
|
||||
if (!rect) return null;
|
||||
const deg = Cesium.Math.toDegrees;
|
||||
return {
|
||||
west: deg(rect.west),
|
||||
south: deg(rect.south),
|
||||
east: deg(rect.east),
|
||||
north: deg(rect.north),
|
||||
};
|
||||
},
|
||||
pose() {
|
||||
const v = (c) => ({ x: c.x, y: c.y, z: c.z });
|
||||
// Cesium has no cartographic position for an eye at the earth's
|
||||
// centre; the pose then says so with NaN.
|
||||
const carto = camera.positionCartographic ?? {
|
||||
longitude: NaN,
|
||||
latitude: NaN,
|
||||
height: NaN,
|
||||
};
|
||||
return {
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
position: v(camera.positionWC),
|
||||
direction: v(camera.directionWC),
|
||||
up: v(camera.upWC),
|
||||
right: v(camera.rightWC),
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
height: carto.height,
|
||||
fovy: camera.frustum.fovy,
|
||||
aspectRatio: camera.frustum.aspectRatio,
|
||||
near: camera.frustum.near,
|
||||
far: camera.frustum.far,
|
||||
frameIsIdentity: Cesium.Matrix4.equals(
|
||||
camera.transform,
|
||||
Cesium.Matrix4.IDENTITY,
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
port: cameraPort,
|
||||
dispose() {
|
||||
for (const off of removers.splice(0)) off();
|
||||
cameraEvents.clear();
|
||||
commandHooks.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -19,9 +19,10 @@ import {
|
||||
import { runCameraScript } from '../conformance/cameraRunner.js';
|
||||
import { updateCameraChanged } from './internals.js';
|
||||
|
||||
const FRAME_MS = 1000 / 60;
|
||||
|
||||
/** The contract's easings as Cesium's. */
|
||||
/**
|
||||
* The contract's easings as Cesium's, kept here rather than taken from the
|
||||
* port: the traces are the reference the port is checked against.
|
||||
*/
|
||||
const EASINGS = {
|
||||
linear: Cesium.EasingFunction.LINEAR_NONE,
|
||||
quadraticInOut: Cesium.EasingFunction.QUADRATIC_IN_OUT,
|
||||
@@ -30,8 +31,11 @@ const EASINGS = {
|
||||
quinticInOut: Cesium.EasingFunction.QUINTIC_IN_OUT,
|
||||
};
|
||||
|
||||
/** One frame of the virtual clock, ms. */
|
||||
export const FRAME_MS = 1000 / 60;
|
||||
|
||||
/** Run `fn` with `performance.now()` reading the virtual clock. */
|
||||
async function withVirtualClock(fn) {
|
||||
export async function withVirtualClock(fn) {
|
||||
const real = Object.getOwnPropertyDescriptor(globalThis, 'performance');
|
||||
const clock = { now: 0 };
|
||||
Object.defineProperty(globalThis, 'performance', {
|
||||
@@ -45,7 +49,8 @@ async function withVirtualClock(fn) {
|
||||
}
|
||||
}
|
||||
|
||||
function headlessCamera() {
|
||||
/** A Cesium camera on a scene with no globe, sized as the scripts' viewport. */
|
||||
export function headlessCamera() {
|
||||
const scene = {
|
||||
canvas: {
|
||||
clientWidth: SCRIPT_VIEWPORT.width,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* The Cesium port's camera commands against the reference traces: the
|
||||
* conformance camera checks run on the port over the same headless camera
|
||||
* and virtual clock the traces were recorded with, so a mapping of the
|
||||
* contract onto Cesium that differs from the recorder's shows.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
CAMERA_SCRIPTS,
|
||||
DEFAULT_TOLERANCE,
|
||||
runConformance,
|
||||
} from '../conformance/index.js';
|
||||
import { FRAME_MS, headlessCamera, withVirtualClock } from './cameraGoldens.js';
|
||||
import { createCesiumCamera } from './camera.js';
|
||||
import { createCesiumPorts } from './ports.js';
|
||||
|
||||
/** Records the headless scene cannot answer: they need the query port. */
|
||||
const QUERY_RECORDS = new Set([
|
||||
'project',
|
||||
'pickEllipsoid',
|
||||
'pickRay',
|
||||
'occluded',
|
||||
]);
|
||||
/** Commands and records the port does not have yet. */
|
||||
const MISSING = new Set(['move', 'look', 'rotateAround', 'zoom', 'matrices']);
|
||||
|
||||
const runnable = CAMERA_SCRIPTS.filter((script) =>
|
||||
script.steps.every(
|
||||
(step) =>
|
||||
!MISSING.has(step.do) &&
|
||||
!MISSING.has(step.record) &&
|
||||
!QUERY_RECORDS.has(step.record),
|
||||
),
|
||||
).map((script) => `camera.trace.${script.name}`);
|
||||
|
||||
const CHECKS = new Set([...runnable, 'camera.flight.callbacks']);
|
||||
|
||||
function headlessHarness(clock) {
|
||||
return {
|
||||
name: 'cesium (headless camera)',
|
||||
async create() {
|
||||
const { scene } = headlessCamera();
|
||||
for (const name of ['preUpdate', 'postUpdate', 'preRender', 'postRender'])
|
||||
scene[name] = new Cesium.Event();
|
||||
scene.primitives = { add() {}, remove() {} };
|
||||
scene.requestRender = () => {};
|
||||
clock.now = 0;
|
||||
const ports = createCesiumPorts(
|
||||
{ scene, clock: { currentTime: Cesium.JulianDate.now() } },
|
||||
{ capabilities: new Set() },
|
||||
);
|
||||
return {
|
||||
camera: ports.camera,
|
||||
query: ports.query,
|
||||
destroy: () => ports.dispose(),
|
||||
frameScene() {
|
||||
clock.now += FRAME_MS;
|
||||
scene.tweens.update();
|
||||
scene.postUpdate.raiseEvent(scene);
|
||||
},
|
||||
};
|
||||
},
|
||||
frame: async (r) => r.frameScene(),
|
||||
now: () => clock.now,
|
||||
toEcef: (p) => {
|
||||
const e =
|
||||
'lon' in p
|
||||
? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height ?? 0)
|
||||
: p;
|
||||
return { x: e.x, y: e.y, z: e.z };
|
||||
},
|
||||
tolerance: DEFAULT_TOLERANCE,
|
||||
flightPaths: 'trace',
|
||||
};
|
||||
}
|
||||
|
||||
test('the camera port retraces the reference flights, views and frames', async () => {
|
||||
assert.ok(runnable.length >= 10, `only ${runnable.length} scripts run`);
|
||||
const report = await withVirtualClock((clock) =>
|
||||
runConformance(headlessHarness(clock), { only: (id) => CHECKS.has(id) }),
|
||||
);
|
||||
const failed = report.results.filter((r) => r.status === 'fail');
|
||||
assert.deepEqual(
|
||||
failed.map((r) => `${r.id}: ${r.detail}`),
|
||||
[],
|
||||
);
|
||||
assert.equal(report.passed, CHECKS.size);
|
||||
});
|
||||
|
||||
test('a cancelled flight has let go before its callbacks, so they can fly again', async () => {
|
||||
await withVirtualClock(async (clock) => {
|
||||
const { scene, camera } = headlessCamera();
|
||||
scene.postUpdate = new Cesium.Event();
|
||||
scene.requestRender = () => {};
|
||||
const port = createCesiumCamera({
|
||||
scene,
|
||||
camera,
|
||||
report: (error) => {
|
||||
throw error;
|
||||
},
|
||||
}).port;
|
||||
const seen = [];
|
||||
let replacement = null;
|
||||
port.flyTo(
|
||||
{ lon: 0, lat: 0, height: 1e6 },
|
||||
{
|
||||
durationS: 1,
|
||||
onCancel: () => {
|
||||
seen.push(['cancelled, flying', port.flying]);
|
||||
replacement = port.flyTo(
|
||||
{ lon: 1, lat: 1, height: 1e6 },
|
||||
{ durationS: 0.2, onComplete: () => seen.push(['replaced']) },
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
port.cancelFlight();
|
||||
assert.deepEqual(seen, [['cancelled, flying', false]]);
|
||||
assert.ok(port.flying, 'the replacement flies');
|
||||
for (let i = 0; i < 30 && port.flying; i++) {
|
||||
clock.now += FRAME_MS;
|
||||
scene.tweens.update();
|
||||
}
|
||||
assert.equal(await replacement.done, 'complete');
|
||||
assert.deepEqual(seen.at(-1), ['replaced']);
|
||||
|
||||
// Superseded by a new flyTo: the old one's onCancel still runs inside
|
||||
// that call, and sees the new flight flying.
|
||||
const order = [];
|
||||
port.flyTo(
|
||||
{ lon: 2, lat: 2, height: 1e6 },
|
||||
{
|
||||
durationS: 1,
|
||||
onCancel: () => order.push(['old cancelled', port.flying]),
|
||||
},
|
||||
);
|
||||
port.flyTo({ lon: 3, lat: 3, height: 1e6 }, { durationS: 1 });
|
||||
assert.deepEqual(order, [['old cancelled', true]]);
|
||||
});
|
||||
});
|
||||
|
||||
function headlessPort() {
|
||||
const { scene, camera } = headlessCamera();
|
||||
scene.postUpdate = new Cesium.Event();
|
||||
scene.requestRender = () => {};
|
||||
const port = createCesiumCamera({
|
||||
scene,
|
||||
camera,
|
||||
report: (error) => {
|
||||
throw error;
|
||||
},
|
||||
}).port;
|
||||
return { scene, camera, port };
|
||||
}
|
||||
|
||||
test('commands announce themselves first; flights raise flightEnd once each', () => {
|
||||
const { port } = headlessPort();
|
||||
const seen = [];
|
||||
const off = port.onCommand((c) => seen.push(c.kind));
|
||||
port.on('flightEnd', () => seen.push('flightEnd'));
|
||||
const target = Cesium.Cartesian3.fromDegrees(0, 0, 0);
|
||||
port.setView({ destination: { lon: 0, lat: 0, height: 1e6 } });
|
||||
port.lookAt(target, { heading: 0, pitch: -0.5, range: 1000 });
|
||||
port.release();
|
||||
port.flyTo({ lon: 1, lat: 1, height: 1e6 }, { durationS: 1 });
|
||||
port.flyToBounds({ center: target, radius: 10 }, { durationS: 1 });
|
||||
port.cancelFlight();
|
||||
off();
|
||||
port.setView({ destination: { lon: 0, lat: 0, height: 1e6 } });
|
||||
assert.deepEqual(seen, [
|
||||
'setView',
|
||||
'lookAt',
|
||||
'release',
|
||||
'flyTo',
|
||||
'flyToBounds',
|
||||
'flightEnd',
|
||||
'cancelFlight',
|
||||
'flightEnd',
|
||||
]);
|
||||
});
|
||||
|
||||
test('endFrame world ends a flight out of a lookAt frame', async () => {
|
||||
await withVirtualClock(async (clock) => {
|
||||
const { scene, port } = headlessPort();
|
||||
const target = Cesium.Cartesian3.fromDegrees(0, 0, 0);
|
||||
port.lookAt(target, { heading: 0, pitch: -0.5, range: 1000 });
|
||||
assert.equal(port.pose().frameIsIdentity, false);
|
||||
const flight = port.flyTo(
|
||||
{ lon: 1, lat: 1, height: 5000 },
|
||||
{ durationS: 0.2, endFrame: 'world' },
|
||||
);
|
||||
for (let i = 0; i < 30 && port.flying; i++) {
|
||||
clock.now += FRAME_MS;
|
||||
scene.tweens.update();
|
||||
}
|
||||
assert.equal(await flight.done, 'complete');
|
||||
assert.equal(port.pose().frameIsIdentity, true);
|
||||
});
|
||||
});
|
||||
|
||||
test('a flight Cesium does not start ends cancelled rather than never', async () => {
|
||||
const { camera, port } = headlessPort();
|
||||
// Mid scene-mode morph Cesium returns without flying or calling back.
|
||||
camera._mode = Cesium.SceneMode.MORPHING;
|
||||
const flight = port.flyTo({ lon: 1, lat: 1, height: 1e6 }, { durationS: 1 });
|
||||
assert.equal(await flight.done, 'cancelled');
|
||||
assert.equal(port.flying, false);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @module render/cesium/common
|
||||
* @description What the Cesium ports share: the contract's listener rules
|
||||
* and its positions as Cesium's.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* One subscription list under the contract's listener rules: subscription
|
||||
* order, one removed during delivery does not run, one added during
|
||||
* delivery waits for the next event, and a throw goes to `onThrow` while
|
||||
* the rest still run. Each subscription is its own entry, so a callback
|
||||
* subscribed twice runs twice.
|
||||
* @param {(error: unknown) => void} onThrow
|
||||
*/
|
||||
export function listenerList(onThrow) {
|
||||
const entries = [];
|
||||
return {
|
||||
get size() {
|
||||
return entries.length;
|
||||
},
|
||||
on(cb) {
|
||||
const entry = { cb, live: true };
|
||||
entries.push(entry);
|
||||
return () => {
|
||||
entry.live = false;
|
||||
const i = entries.indexOf(entry);
|
||||
if (i >= 0) entries.splice(i, 1);
|
||||
};
|
||||
},
|
||||
fire(...args) {
|
||||
for (const entry of entries.slice()) {
|
||||
if (!entry.live) continue;
|
||||
try {
|
||||
entry.cb(...args);
|
||||
} catch (error) {
|
||||
onThrow(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
for (const entry of entries) entry.live = false;
|
||||
entries.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A contract position as ECEF. */
|
||||
export function toCartesian(pos) {
|
||||
if ('x' in pos) return new Cesium.Cartesian3(pos.x, pos.y, pos.z);
|
||||
return Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, pos.height ?? 0);
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
* `CESIUM_CHECKS` lists the checks of the ports the Cesium renderer has.
|
||||
* Each migration adds the checks of the members it brings, so the suite
|
||||
* shows how far the renderer is through the contract; every other check is
|
||||
* left out of the run rather than failed.
|
||||
* left out of the run rather than failed. Camera flights are checked in
|
||||
* Node instead (`cameraPort.test.mjs`): a page's frames come on the real
|
||||
* clock at the display's rate, where the reference traces step 1/60 s.
|
||||
*/
|
||||
import { DEFAULT_TOLERANCE } from '../conformance/index.js';
|
||||
import { toEcef } from '../stub/math.js';
|
||||
@@ -14,6 +16,11 @@ import { createCesiumSceneRenderer, pointerFeedOf } from './sceneRenderer.js';
|
||||
|
||||
/** @type {ReadonlySet<string>} */
|
||||
export const CESIUM_CHECKS = new Set([
|
||||
'camera.trace.setView angles',
|
||||
'camera.trace.setView keeps the position when only orientation is given',
|
||||
'camera.trace.lookAt sweep and release',
|
||||
'camera.trace.flyTo with a duration of 0 completes inside the call',
|
||||
'camera.trace.flyTo where the camera already is completes inside the call',
|
||||
'camera.viewRectangle',
|
||||
'draw.drillPick',
|
||||
'draw.ids',
|
||||
|
||||
+7
-122
@@ -13,7 +13,8 @@
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { heightsFor } from '../../queries/heights.js';
|
||||
import { createCameraEvents } from '../camera/events.js';
|
||||
import { createCesiumCamera } from './camera.js';
|
||||
import { listenerList, toCartesian } from './common.js';
|
||||
import {
|
||||
atEndOfTick,
|
||||
billboardLoadError,
|
||||
@@ -22,6 +23,8 @@ import {
|
||||
renderPending,
|
||||
} from './internals.js';
|
||||
|
||||
export { listenerList, toCartesian };
|
||||
|
||||
/**
|
||||
* Where a listener's throw goes with no `error` listener: the platform's
|
||||
* `reportError` (or a rethrow on a microtask), as a browser reports an event
|
||||
@@ -37,46 +40,6 @@ function uncaught(error) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One subscription list under the contract's listener rules: subscription
|
||||
* order, one removed during delivery does not run, one added during
|
||||
* delivery waits for the next event, and a throw goes to `onThrow` while
|
||||
* the rest still run. Each subscription is its own entry, so a callback
|
||||
* subscribed twice runs twice.
|
||||
* @param {(error: unknown) => void} onThrow
|
||||
*/
|
||||
export function listenerList(onThrow) {
|
||||
const entries = [];
|
||||
return {
|
||||
get size() {
|
||||
return entries.length;
|
||||
},
|
||||
on(cb) {
|
||||
const entry = { cb, live: true };
|
||||
entries.push(entry);
|
||||
return () => {
|
||||
entry.live = false;
|
||||
const i = entries.indexOf(entry);
|
||||
if (i >= 0) entries.splice(i, 1);
|
||||
};
|
||||
},
|
||||
fire(...args) {
|
||||
for (const entry of entries.slice()) {
|
||||
if (!entry.live) continue;
|
||||
try {
|
||||
entry.cb(...args);
|
||||
} catch (error) {
|
||||
onThrow(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
for (const entry of entries) entry.live = false;
|
||||
entries.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The contract's colour as Cesium's. */
|
||||
export function toColor(color, alpha = 1) {
|
||||
if (color === undefined || color === null)
|
||||
@@ -97,12 +60,6 @@ export function toImage(image) {
|
||||
throw new Error('the Cesium renderer takes URL, SVG and canvas images only');
|
||||
}
|
||||
|
||||
/** A contract position as ECEF. */
|
||||
export function toCartesian(pos) {
|
||||
if ('x' in pos) return new Cesium.Cartesian3(pos.x, pos.y, pos.z);
|
||||
return Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, pos.height ?? 0);
|
||||
}
|
||||
|
||||
const ANCHORS = {
|
||||
center: Cesium.VerticalOrigin.CENTER,
|
||||
bottom: Cesium.VerticalOrigin.BOTTOM,
|
||||
@@ -789,80 +746,8 @@ export function createCesiumPorts(widget, { capabilities }) {
|
||||
models: (spec) => makeSet('models', { ...spec }, buildModels),
|
||||
};
|
||||
|
||||
// Camera events: Cesium's own moveStart and moveEnd (500 ms after the
|
||||
// last change, never while following), and `changed` measured per
|
||||
// listener on every tick, where Cesium measures its single threshold.
|
||||
const cameraEvents = createCameraEvents({
|
||||
pose: () => cameraPort.pose(),
|
||||
report,
|
||||
});
|
||||
removers.push(
|
||||
scene.postUpdate.addEventListener(() => {
|
||||
if (!disposed) cameraEvents.stepChanged();
|
||||
}),
|
||||
camera.moveStart.addEventListener(() => cameraEvents.emit('moveStart')),
|
||||
camera.moveEnd.addEventListener(() => cameraEvents.emit('moveEnd')),
|
||||
);
|
||||
|
||||
const cameraPort = {
|
||||
on: (event, cb, options) => cameraEvents.on(event, cb, options),
|
||||
setView(spec) {
|
||||
const d = spec.destination;
|
||||
if (d && 'rect' in d)
|
||||
throw new Error('the Cesium renderer takes position destinations only');
|
||||
const o = spec.orientation ?? {};
|
||||
camera.setView({
|
||||
...(d ? { destination: toCartesian(d) } : {}),
|
||||
orientation: {
|
||||
heading: o.heading ?? 0,
|
||||
pitch: o.pitch ?? -Math.PI / 2,
|
||||
roll: o.roll ?? 0,
|
||||
},
|
||||
});
|
||||
scene.requestRender();
|
||||
},
|
||||
viewRectangle() {
|
||||
const rect = camera.computeViewRectangle(Cesium.Ellipsoid.WGS84);
|
||||
if (!rect) return null;
|
||||
const deg = Cesium.Math.toDegrees;
|
||||
return {
|
||||
west: deg(rect.west),
|
||||
south: deg(rect.south),
|
||||
east: deg(rect.east),
|
||||
north: deg(rect.north),
|
||||
};
|
||||
},
|
||||
pose() {
|
||||
const v = (c) => ({ x: c.x, y: c.y, z: c.z });
|
||||
// Cesium has no cartographic position for an eye at the earth's
|
||||
// centre; the pose then says so with NaN.
|
||||
const carto = camera.positionCartographic ?? {
|
||||
longitude: NaN,
|
||||
latitude: NaN,
|
||||
height: NaN,
|
||||
};
|
||||
return {
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
position: v(camera.positionWC),
|
||||
direction: v(camera.directionWC),
|
||||
up: v(camera.upWC),
|
||||
right: v(camera.rightWC),
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
height: carto.height,
|
||||
fovy: camera.frustum.fovy,
|
||||
aspectRatio: camera.frustum.aspectRatio,
|
||||
near: camera.frustum.near,
|
||||
far: camera.frustum.far,
|
||||
frameIsIdentity: Cesium.Matrix4.equals(
|
||||
camera.transform,
|
||||
Cesium.Matrix4.IDENTITY,
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
const cameraPart = createCesiumCamera({ scene, camera, report });
|
||||
const cameraPort = cameraPart.port;
|
||||
|
||||
// --- Query -----------------------------------------------------------------
|
||||
|
||||
@@ -1220,7 +1105,7 @@ export function createCesiumPorts(widget, { capabilities }) {
|
||||
// A listener that disposes the renderer stops the rest of its event.
|
||||
for (const list of Object.values(rendererListeners)) list.clear();
|
||||
for (const list of Object.values(frameListeners)) list.clear();
|
||||
cameraEvents.clear();
|
||||
cameraPart.dispose();
|
||||
for (const done of [...pendingRenders]) done();
|
||||
// Late model loads find no host and destroy themselves.
|
||||
for (const record of sets.values()) record.host = null;
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"src/app/embed.js",
|
||||
"src/app/scene.js",
|
||||
"src/app/viewer.js",
|
||||
"src/camera.js",
|
||||
"src/cameraGroundGuard.js",
|
||||
"src/cameraVerbs.js",
|
||||
"src/celestialRing.js",
|
||||
@@ -218,7 +217,6 @@
|
||||
"src/scenes/director.js",
|
||||
"src/services/groundSnap.js",
|
||||
"src/services/meshFloorSampler.js",
|
||||
"src/sharelink.js",
|
||||
"src/sky/starrySky.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
|
||||
@@ -27,3 +27,15 @@ export function bindRenderer(host, renderer) {
|
||||
* @returns {object|null} The renderer bound to `host`, or null.
|
||||
*/
|
||||
export const rendererOf = (host) => (host && renderers.get(host)) || null;
|
||||
|
||||
/**
|
||||
* The camera port of the renderer bound to `host`.
|
||||
* @param {object} host
|
||||
* @returns {import('./contract/camera').CameraPort}
|
||||
* @throws When no renderer is bound to `host`.
|
||||
*/
|
||||
export function cameraOf(host) {
|
||||
const camera = rendererOf(host)?.camera;
|
||||
if (!camera) throw new Error('This scene has no renderer camera');
|
||||
return camera;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { bindRenderer, rendererOf } from './rendererOf.js';
|
||||
import { bindRenderer, cameraOf, rendererOf } from './rendererOf.js';
|
||||
|
||||
test('a scene finds its renderer until it is unbound', () => {
|
||||
const scene = {};
|
||||
@@ -23,3 +23,12 @@ test('a stale unbind leaves a later binding alone', () => {
|
||||
unbindFirst();
|
||||
assert.equal(rendererOf(scene), second);
|
||||
});
|
||||
|
||||
test('cameraOf names the missing renderer instead of returning nothing', () => {
|
||||
const scene = {};
|
||||
assert.throws(() => cameraOf(scene), /no renderer camera/);
|
||||
const camera = { pose() {} };
|
||||
const unbind = bindRenderer(scene, { camera });
|
||||
assert.equal(cameraOf(scene), camera);
|
||||
unbind();
|
||||
});
|
||||
@@ -456,12 +456,13 @@ test('share-link restore forces a final stationary render for Google 3D Tiles',
|
||||
|
||||
assert.ok(calls.flyTo, 'restore must start a camera flight');
|
||||
assert.equal(typeof calls.flyTo.complete, 'function');
|
||||
const rendersInFlight = calls.renders;
|
||||
calls.flyTo.complete();
|
||||
assert.deepEqual(calls.setView, {
|
||||
destination: calls.flyTo.destination,
|
||||
orientation: calls.flyTo.orientation,
|
||||
});
|
||||
assert.equal(calls.renders, 1);
|
||||
assert.equal(calls.renders, rendersInFlight + 1, 'one render at rest');
|
||||
});
|
||||
|
||||
test('newer navigation suppresses delayed share camera while non-camera state still restores', async () => {
|
||||
|
||||
+9
-13
@@ -1,5 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { fromDegrees, toDegrees, toRadians } from './geo/index.js';
|
||||
import { toDegrees, toRadians } from './geo/index.js';
|
||||
import { rendererOf } from './render/rendererOf.js';
|
||||
import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from './bloom.js';
|
||||
import {
|
||||
@@ -333,9 +332,7 @@ export class ShareLinkManager {
|
||||
if (this._destroyed || !state)
|
||||
return { succeeded: false, reason: 'unavailable' };
|
||||
const view = {
|
||||
destination: Cesium.Cartesian3.clone(
|
||||
fromDegrees(state.lon, state.lat, state.alt),
|
||||
),
|
||||
destination: { lon: state.lon, lat: state.lat, height: state.alt },
|
||||
orientation: {
|
||||
heading: toRadians(state.heading),
|
||||
pitch: toRadians(state.pitch),
|
||||
@@ -364,11 +361,11 @@ export class ShareLinkManager {
|
||||
};
|
||||
// Re-apply the final pose only while this share restoration still owns
|
||||
// navigation. A later user or voice command wins over delayed restore.
|
||||
this.viewer.camera.flyTo({
|
||||
...view,
|
||||
duration: 3.0,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
complete: () => {
|
||||
this._camera.flyTo(view.destination, {
|
||||
orientation: view.orientation,
|
||||
durationS: 3.0,
|
||||
easing: 'cubicInOut',
|
||||
onComplete: () => {
|
||||
if (
|
||||
this._destroyed ||
|
||||
restoreGeneration !== this._restoreGeneration ||
|
||||
@@ -377,11 +374,10 @@ export class ShareLinkManager {
|
||||
releaseOwnedFlight('superseded');
|
||||
return;
|
||||
}
|
||||
this.viewer.camera.setView(view);
|
||||
this.viewer.scene?.requestRender?.();
|
||||
this._camera.setView(view);
|
||||
releaseOwnedFlight('applied');
|
||||
},
|
||||
cancel: () => releaseOwnedFlight('cancelled'),
|
||||
onCancel: () => releaseOwnedFlight('cancelled'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { createCameraEvents } from '../render/camera/events.js';
|
||||
import { toDegrees } from '../geo/index.js';
|
||||
import { bindRenderer, rendererOf } from '../render/rendererOf.js';
|
||||
import { createCesiumCamera } from '../render/cesium/camera.js';
|
||||
|
||||
const DEFAULT_POSE = Object.freeze({
|
||||
lon: 0,
|
||||
@@ -64,38 +65,94 @@ export function fakeCameraPort({ pose = {}, viewRectangle = null } = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind to a fake viewer a camera port that reads its Cesium-shaped fake
|
||||
* camera live (`positionCartographic`, `heading`, `pitch`, `roll`, and the
|
||||
* `changed`/`moveStart`/`moveEnd` events it has), for tests whose code
|
||||
* still moves that camera directly. Returns the viewer.
|
||||
* Bind to a fake viewer the Cesium renderer's own camera port over its
|
||||
* Cesium-shaped fake camera, for tests whose code is half on the port: the
|
||||
* port's commands reach the fake camera as the renderer maps them
|
||||
* (`flyTo({destination: Cartesian3, ...})`), while poses are read leniently
|
||||
* from what the fake has (`positionCartographic`, `heading`, `pitch`,
|
||||
* `roll`) and events come from the fake's own `changed`, `moveStart` and
|
||||
* `moveEnd`. Binding a viewer twice keeps the first. Returns the viewer.
|
||||
* @template {{camera: object}} V
|
||||
* @param {V} viewer
|
||||
* @returns {V}
|
||||
*/
|
||||
export function bindFakeCesiumCamera(viewer) {
|
||||
if (rendererOf(viewer)) return viewer;
|
||||
const camera = viewer.camera;
|
||||
const port = {
|
||||
pose() {
|
||||
const c = camera.positionCartographic;
|
||||
return {
|
||||
...DEFAULT_POSE,
|
||||
lon: c ? toDegrees(c.longitude) : NaN,
|
||||
lat: c ? toDegrees(c.latitude) : NaN,
|
||||
height: c ? c.height : NaN,
|
||||
heading: camera.heading ?? 0,
|
||||
pitch: camera.pitch ?? -Math.PI / 2,
|
||||
roll: camera.roll ?? 0,
|
||||
const fake = viewer.camera;
|
||||
const noEvent = { addEventListener: () => () => {} };
|
||||
// A fake that does not keep Cesium's current flight gets one kept for it,
|
||||
// as Cesium does: from a flight's start until it completes or cancels.
|
||||
let current = null;
|
||||
const flown = (fly) =>
|
||||
function (...args) {
|
||||
const options = args.at(-1) ?? {};
|
||||
const flight = {};
|
||||
current = flight;
|
||||
const settle = (cb) => () => {
|
||||
if (current === flight) current = null;
|
||||
cb?.();
|
||||
};
|
||||
args[args.length - 1] = {
|
||||
...options,
|
||||
complete: settle(options.complete),
|
||||
cancel: settle(options.cancel),
|
||||
};
|
||||
return fly.apply(this, args);
|
||||
};
|
||||
const keepsFlight = '_currentFlight' in fake;
|
||||
const camera = new Proxy(fake, {
|
||||
get(target, key, receiver) {
|
||||
if ((key === 'moveStart' || key === 'moveEnd') && !target[key])
|
||||
return noEvent;
|
||||
if (!keepsFlight) {
|
||||
if (key === '_currentFlight') return current ?? undefined;
|
||||
if ((key === 'flyTo' || key === 'flyToBoundingSphere') && target[key])
|
||||
return flown(target[key]);
|
||||
if (key === 'cancelFlight' && target[key])
|
||||
return function (...args) {
|
||||
current = null;
|
||||
return target[key].apply(this, args);
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, key, receiver);
|
||||
},
|
||||
viewRectangle: () => null,
|
||||
on(event, cb) {
|
||||
const source = camera[event];
|
||||
const off = source?.addEventListener?.(cb);
|
||||
if (typeof off === 'function') return off;
|
||||
return () => source?.removeEventListener?.(cb);
|
||||
},
|
||||
});
|
||||
const scene = {
|
||||
postUpdate: noEvent,
|
||||
requestRender: () => viewer.scene?.requestRender?.(),
|
||||
};
|
||||
const real = createCesiumCamera({
|
||||
scene,
|
||||
camera,
|
||||
report: (error) => {
|
||||
throw error;
|
||||
},
|
||||
}).port;
|
||||
const port = Object.create(real, {
|
||||
pose: {
|
||||
value() {
|
||||
const c = fake.positionCartographic;
|
||||
return {
|
||||
...DEFAULT_POSE,
|
||||
lon: c ? toDegrees(c.longitude) : NaN,
|
||||
lat: c ? toDegrees(c.latitude) : NaN,
|
||||
height: c ? c.height : NaN,
|
||||
heading: fake.heading ?? 0,
|
||||
pitch: fake.pitch ?? -Math.PI / 2,
|
||||
roll: fake.roll ?? 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
viewRectangle: { value: () => null },
|
||||
on: {
|
||||
value(event, cb) {
|
||||
const source = fake[event];
|
||||
const off = source?.addEventListener?.(cb);
|
||||
if (typeof off === 'function') return off;
|
||||
return () => source?.removeEventListener?.(cb);
|
||||
},
|
||||
},
|
||||
});
|
||||
bindRenderer(viewer, { camera: port });
|
||||
return viewer;
|
||||
}
|
||||
+13
-14
@@ -30,6 +30,7 @@ import {
|
||||
} from './cyberSonarControls.js';
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { cameraOf } from '../render/rendererOf.js';
|
||||
|
||||
import { aircraftTrackingTarget } from '../cockpitTracking.js';
|
||||
|
||||
@@ -441,7 +442,7 @@ export class StyleManager extends ShellFacade {
|
||||
},
|
||||
isNavigationCurrent: (generation) =>
|
||||
generation === this._navigationGeneration,
|
||||
cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(),
|
||||
cancelOwnedNavigation: () => cameraOf(this.viewer).cancelFlight(),
|
||||
// A host may open a share code without a `/share/<code>` address.
|
||||
initialShareCode,
|
||||
});
|
||||
@@ -1452,20 +1453,18 @@ export class StyleManager extends ShellFacade {
|
||||
*/
|
||||
applyCameraState(cameraState, duration = 2.8) {
|
||||
if (!cameraState) return;
|
||||
this.viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
cameraState.lon,
|
||||
cameraState.lat,
|
||||
cameraState.alt,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(cameraState.heading || 0),
|
||||
pitch: Cesium.Math.toRadians(cameraState.pitch || -35),
|
||||
roll: Cesium.Math.toRadians(cameraState.roll || 0),
|
||||
cameraOf(this.viewer).flyTo(
|
||||
{ lon: cameraState.lon, lat: cameraState.lat, height: cameraState.alt },
|
||||
{
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(cameraState.heading || 0),
|
||||
pitch: Cesium.Math.toRadians(cameraState.pitch || -35),
|
||||
roll: Cesium.Math.toRadians(cameraState.roll || 0),
|
||||
},
|
||||
durationS: Math.max(0.2, duration || 0),
|
||||
easing: 'cubicInOut',
|
||||
},
|
||||
duration: Math.max(0.2, duration || 0),
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,7 @@ import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
|
||||
import { presentResult } from './resultDisplay.js';
|
||||
import { attachVoiceResult } from './speech.js';
|
||||
import { withEntityRecord } from '../data/entityRecords.js';
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
|
||||
test('every live basemap is reachable by its own id — no enum value without a voice alias', () => {
|
||||
// B1 regression: a stack added to MAP_STACKS (and the set_map_stack enum)
|
||||
@@ -131,6 +132,7 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) {
|
||||
lookAtTransform() {},
|
||||
},
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
const styleManager = {
|
||||
runImmediateNavigation(noun, navigate, releaseOptions = undefined) {
|
||||
return runExplicitNavigation({
|
||||
@@ -3288,7 +3290,9 @@ test('voice Radio: the keyless path applies no country filter the keyed path wou
|
||||
});
|
||||
|
||||
const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ });
|
||||
function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); }
|
||||
/** Fake viewers with a fake Cesium camera reach the renderer's camera port too. */
|
||||
const withCameraPort = (viewer) => (viewer?.camera ? bindFakeCesiumCamera(viewer) : viewer);
|
||||
function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options, viewer: withCameraPort(options?.viewer) }); }
|
||||
function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); }
|
||||
|
||||
test('ALPR common names toggle only the registered camera layer through the normal voice action', async () => {
|
||||
|
||||
Reference in new issue
Block a user