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:
Sameh Khamis committed 2026-11-05 20:15:52 -08:00
1 parent de0ff6cd2d
commit f95324acb1
20 files changed
+846 -305

No files matched your search

+5
View File
@@ -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",
+55 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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']);
});
-7
View File
@@ -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',
+266
View File
@@ -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();
},
};
}
+10 -5
View File
@@ -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,
+210
View File
@@ -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);
});
+52
View File
@@ -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);
}
+8 -1
View File
@@ -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
View File
@@ -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;
-2
View File
@@ -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",
+12
View File
@@ -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;
}
+10 -1
View File
@@ -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();
});
+2 -1
View File
@@ -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
View File
@@ -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'),
});
}
+80 -23
View File
@@ -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
View File
@@ -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,
});
);
}
/**
+5 -1
View File
@@ -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 () => {