From f95324acb159f645ea2780f8f0384e9fe8f8f8b1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 20:15:52 -0800 Subject: [PATCH] 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. --- scripts/package-boundaries.json | 5 + src/app/startupCamera.test.mjs | 67 +++++-- src/camera.js | 34 ++-- src/locations.js | 121 ++++++----- src/locations.test.mjs | 50 ++--- src/render/cesium/appRenderer.test.mjs | 7 - src/render/cesium/camera.js | 266 +++++++++++++++++++++++++ src/render/cesium/cameraGoldens.js | 15 +- src/render/cesium/cameraPort.test.mjs | 210 +++++++++++++++++++ src/render/cesium/common.js | 52 +++++ src/render/cesium/harness.js | 9 +- src/render/cesium/ports.js | 129 +----------- src/render/cesiumAllowList.json | 2 - src/render/rendererOf.js | 12 ++ src/render/rendererOf.test.mjs | 11 +- src/sharelink.celestial.test.mjs | 3 +- src/sharelink.js | 22 +- src/testSupport/fakeCameraPort.mjs | 103 +++++++--- src/ui/applicationShell.js | 27 ++- src/voice/gevActions.test.mjs | 6 +- 20 files changed, 846 insertions(+), 305 deletions(-) create mode 100644 src/render/cesium/camera.js create mode 100644 src/render/cesium/cameraPort.test.mjs create mode 100644 src/render/cesium/common.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index dfe81dbfc..e8a5663d8 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/app/startupCamera.test.mjs b/src/app/startupCamera.test.mjs index 721f9964b..fadd92447 100644 --- a/src/app/startupCamera.test.mjs +++ b/src/app/startupCamera.test.mjs @@ -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'); }); diff --git a/src/camera.js b/src/camera.js index 3fd49b58c..24f0adda7 100644 --- a/src/camera.js +++ b/src/camera.js @@ -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(); }; } diff --git a/src/locations.js b/src/locations.js index a3cddf17d..b954cbe71 100644 --- a/src/locations.js +++ b/src/locations.js @@ -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 { diff --git a/src/locations.test.mjs b/src/locations.test.mjs index 8d2a9503c..3c723ed5d 100644 --- a/src/locations.test.mjs +++ b/src/locations.test.mjs @@ -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']); }); diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index d6eb0735d..357e127b5 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -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', diff --git a/src/render/cesium/camera.js b/src/render/cesium/camera.js new file mode 100644 index 000000000..d377cb729 --- /dev/null +++ b/src/render/cesium/camera.js @@ -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(); + }, + }; +} diff --git a/src/render/cesium/cameraGoldens.js b/src/render/cesium/cameraGoldens.js index f68665464..e8550718a 100644 --- a/src/render/cesium/cameraGoldens.js +++ b/src/render/cesium/cameraGoldens.js @@ -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, diff --git a/src/render/cesium/cameraPort.test.mjs b/src/render/cesium/cameraPort.test.mjs new file mode 100644 index 000000000..7dc0bd95a --- /dev/null +++ b/src/render/cesium/cameraPort.test.mjs @@ -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); +}); diff --git a/src/render/cesium/common.js b/src/render/cesium/common.js new file mode 100644 index 000000000..c006d0740 --- /dev/null +++ b/src/render/cesium/common.js @@ -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); +} diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index 54869d192..dad47c267 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -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} */ 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', diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index 329fd2e7e..f46edf9d2 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -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; diff --git a/src/render/cesiumAllowList.json b/src/render/cesiumAllowList.json index 282701b34..509200caf 100644 --- a/src/render/cesiumAllowList.json +++ b/src/render/cesiumAllowList.json @@ -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", diff --git a/src/render/rendererOf.js b/src/render/rendererOf.js index 81a314f3e..0103b66c2 100644 --- a/src/render/rendererOf.js +++ b/src/render/rendererOf.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; +} diff --git a/src/render/rendererOf.test.mjs b/src/render/rendererOf.test.mjs index f3d7ac777..9f40e9dd9 100644 --- a/src/render/rendererOf.test.mjs +++ b/src/render/rendererOf.test.mjs @@ -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(); +}); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 3ae1e41a2..f9c65b08a 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -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 () => { diff --git a/src/sharelink.js b/src/sharelink.js index f41f56c26..69851eaca 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -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'), }); } diff --git a/src/testSupport/fakeCameraPort.mjs b/src/testSupport/fakeCameraPort.mjs index 053003431..fde7284ba 100644 --- a/src/testSupport/fakeCameraPort.mjs +++ b/src/testSupport/fakeCameraPort.mjs @@ -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; } diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index c3ad27c14..30af012e4 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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/` 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, - }); + ); } /** diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 1476b4f5e..3bb302521 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -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 () => {