diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fd16e3101..f51c4de0b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -8,12 +8,23 @@ "modules": [ "src/data/infrastructure.js", "src/data/localGeojsonCore.js", + "src/render/cesium/camera.js", "src/data/localGeojsonLod.js", "src/data/inputOwnership.js", "src/data/pickRegistry.js", "src/data/scenePick.js", "src/input/dispatcher.js", "src/render/cesium/cameraInput.js", + "src/render/camera/events.js", + "src/geo/index.js", + "src/geo/celestial.js", + "src/geo/quaternion.js", + "src/geo/frames.js", + "src/geo/ellipsoid.js", + "src/geo/vector.js", + "src/render/rendererOf.js", + "src/render/cesium/common.js", + "src/render/cesium/internals.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", "src/render/input/router.js", @@ -1242,6 +1253,17 @@ "src/data/bhoteKoshiFloodPath.js", "src/maps/catalog.js", "src/scenes/director.js", + "src/render/cesium/camera.js", + "src/geo/index.js", + "src/render/rendererOf.js", + "src/render/camera/events.js", + "src/geo/celestial.js", + "src/geo/quaternion.js", + "src/geo/frames.js", + "src/geo/ellipsoid.js", + "src/geo/vector.js", + "src/render/cesium/internals.js", + "src/render/cesium/common.js", "src/scenes/sceneSaveError.js", "src/scenes/playbackAdapter.js", "src/director/playback.js", diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index d8437a2cb..6c2d72ca5 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1,6 +1,7 @@ import { isUnavailableCapability } from '../sources/capability.js'; import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; +import { cameraOf } from '../render/cesium/camera.js'; import { greatCircleMeters } from '../geoDistance.js'; import { holdContinuousRender, @@ -1700,14 +1701,18 @@ export function createAnnotationEngine({ Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0), ); const sphere = Cesium.BoundingSphere.fromPoints(cart); - viewer.camera.flyToBoundingSphere(sphere, { - offset: new Cesium.HeadingPitchRange( - viewer.camera.heading, - Cesium.Math.toRadians(-35), - Math.max(900, sphere.radius * 2.6), - ), - duration: 1.6, - }); + const camera = cameraOf(viewer); + camera.flyToBounds( + { center: sphere.center, radius: sphere.radius }, + { + offset: { + heading: camera.pose().heading, + pitch: Cesium.Math.toRadians(-35), + range: Math.max(900, sphere.radius * 2.6), + }, + durationS: 1.6, + }, + ); } catch { /* camera assist is best-effort */ } @@ -1757,22 +1762,23 @@ export function createAnnotationEngine({ : anno.ring, ) : viewportRange(anno.viewport) || 600; - viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere( - Cesium.Cartesian3.fromDegrees( - target.lon, - target.lat, - anno.anchor.height || 0, - ), - range, - ), + const camera = cameraOf(viewer); + camera.flyToBounds( { - offset: new Cesium.HeadingPitchRange( - viewer.camera.heading, - Cesium.Math.toRadians(-35), - range * 2.4, - ), - duration: 1.8, + center: { + lon: target.lon, + lat: target.lat, + height: anno.anchor.height || 0, + }, + radius: range, + }, + { + offset: { + heading: camera.pose().heading, + pitch: Cesium.Math.toRadians(-35), + range: range * 2.4, + }, + durationS: 1.8, }, ); } catch { @@ -1978,15 +1984,17 @@ export function createAnnotationEngine({ }) { if (destroyed) return; try { - viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees(lon, lat, height), - orientation: { - heading: Cesium.Math.toRadians(heading), - pitch: Cesium.Math.toRadians(pitch), - roll: 0, + cameraOf(viewer).flyTo( + { lon, lat, height }, + { + orientation: { + heading: Cesium.Math.toRadians(heading), + pitch: Cesium.Math.toRadians(pitch), + roll: 0, + }, + durationS: duration, }, - duration, - }); + ); } catch { /* camera flight best-effort */ } diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 8b29ba5d2..faf66d912 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -1,3 +1,4 @@ +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; import { createStandalonePlaceSearch } from '../standalone/placeSearch.js'; // Voice-annotation resilience contract tests — pure logic, no network, no browser. // @@ -1262,7 +1263,7 @@ test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and const { renderer, calls } = fakeRenderer(); const flights = []; const engine = createAnnotationEngine({ - viewer: { camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } }, + viewer: bindFakeCesiumCamera({ camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } }), renderer, }); let changes = 0; @@ -1331,12 +1332,12 @@ test('hand-drawn marks: remove, hide, clear, expiry and framing have their effec const { renderer, calls } = fakeRenderer(); const flights = []; const engine = createAnnotationEngine({ - viewer: { + viewer: bindFakeCesiumCamera({ camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere), }, - }, + }), renderer, }); let changes = 0; diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index 05ea476da..5829f58b7 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -186,21 +186,28 @@ test('consumer build includes only infrastructure code and resolves assets under const entry = output.find((item) => item.type === 'chunk' && item.isEntry); const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js')); assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [ + 'camera.js', 'cameraInput.js', + 'celestial.js', + 'common.js', 'ctrlWheelZoom.js', 'dispatcher.js', + 'events.js', 'heights.js', 'infrastructure.js', 'infrastructureData.js', 'infrastructureOverlayEntry.js', 'input.js', 'inputOwnership.js', + 'internals.js', 'localGeojsonCore.js', 'localGeojsonLod.js', 'pickRegistry.js', + 'rendererOf.js', 'router.js', 'scenePick.js', 'sensorPick.js', + 'vector.js', ]); assert.deepEqual( entry.imports, diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 683f7e026..bfe1ddf5e 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { cameraOf } from '../render/cesium/camera.js'; import { layerTitle, mapAnalystRecord, @@ -820,6 +821,8 @@ export function createLocalGeoJsonLayer( if (targetPos) { const carto = Cesium.Cartographic.fromCartesian(targetPos); + // Before the hold: with no camera, nothing would end it. + const camera = cameraOf(viewer); // Hold camera input so Cesium doesn't cancel the flight. // A hold, not a flag: the flight ending must not hand input @@ -828,16 +831,18 @@ export function createLocalGeoJsonLayer( 'local-geojson-flight', ); - viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromRadians( + camera.flyTo( + Cesium.Cartesian3.fromRadians( carto.longitude, carto.latitude, 5000, ), - duration: 1.5, - complete: releaseCamera, - cancel: releaseCamera, - }); + { + durationS: 1.5, + onComplete: releaseCamera, + onCancel: releaseCamera, + }, + ); } } }); diff --git a/src/scenes/director.js b/src/scenes/director.js index 051ce89a8..050872168 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -21,6 +21,8 @@ import { createToast } from '../ui/toast.js'; import { buildPlaybackQueue, playSceneQueue } from '../director/playback.js'; import { createScenePlaybackAdapter } from './playbackAdapter.js'; import * as Cesium from 'cesium'; +import { cameraOf } from '../render/cesium/camera.js'; +import { findCamera } from '../render/rendererOf.js'; import { SCENE_APPEND_RECIPES, SCENE_RECIPES, @@ -263,7 +265,8 @@ export class SceneDirector { this.stopScene(msg('Stopped')); this._loadAbort?.abort(); this._loadGeneration++; - this.viewer.camera.cancelFlight(); + // The viewer may be gone by now: then there is no flight to stop. + findCamera(this.viewer)?.cancelFlight(); this._storageToast?.cancel(); await Promise.allSettled(this._pendingWork || []); this._clock.destroy(); @@ -1746,16 +1749,16 @@ export class SceneDirector { /** Place the camera at an authored replay start pose without an intermediate flight. */ _setCameraView(cameraState) { - if (!cameraState || typeof this.viewer?.camera?.setView !== 'function') - return false; + const camera = findCamera(this.viewer); + if (!cameraState || !camera) return false; this.styleManager?.clearSearchedLocation?.(); - this.viewer.camera.cancelFlight?.(); - this.viewer.camera.setView({ - destination: Cesium.Cartesian3.fromDegrees( - cameraState.lon, - cameraState.lat, - cameraState.alt, - ), + camera.cancelFlight(); + camera.setView({ + destination: { + lon: cameraState.lon, + lat: cameraState.lat, + height: cameraState.alt, + }, orientation: { heading: Cesium.Math.toRadians(cameraState.heading || 0), pitch: Cesium.Math.toRadians(cameraState.pitch ?? -35), @@ -2022,7 +2025,8 @@ export class SceneDirector { this._loadGeneration++; this._clock.stopShot(); this._cancelActiveSceneTravel(); - this.viewer.camera.cancelFlight(); + // Stop runs in teardown too, after the viewer may be gone. + findCamera(this.viewer)?.cancelFlight(); this._clock.stop(); if (!this._running || !this._runToken) return; this._runToken.cancelled = true; @@ -2464,11 +2468,11 @@ export class SceneDirector { 0.2, Number(durationSec) || DEFAULT_SHOT_DURATION_SEC, ); - const destination = Cesium.Cartesian3.fromDegrees( - cameraState.lon, - cameraState.lat, - cameraState.alt, - ); + const destination = { + lon: cameraState.lon, + lat: cameraState.lat, + height: cameraState.alt, + }; await new Promise((resolve) => { // Guard against double-resolve from both callback and timeout @@ -2481,17 +2485,16 @@ export class SceneDirector { resolve(); }; - this.viewer.camera.flyTo({ - destination, + cameraOf(this.viewer).flyTo(destination, { orientation: { heading: Cesium.Math.toRadians(cameraState.heading || 0), pitch: Cesium.Math.toRadians(cameraState.pitch ?? -35), roll: Cesium.Math.toRadians(cameraState.roll || 0), }, - duration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - complete: finish, - cancel: finish, + durationS: duration, + easing: 'cubicInOut', + onComplete: finish, + onCancel: finish, }); // Safety timeout in case Cesium callbacks fail to fire diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 80328b040..cffd6d079 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -1,3 +1,4 @@ +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; import { readShellSource } from '../testSupport/readShellSource.mjs'; import { useStatusStore } from '../ui/stores/status.js'; import { applicationPinia } from '../ui/vueRuntime.js'; @@ -1072,7 +1073,7 @@ function fakeViewer() { const flights = []; const views = []; let cancelled = 0; - return { + return bindFakeCesiumCamera({ flights, views, get cancelledFlights() { return cancelled; }, @@ -1086,7 +1087,7 @@ function fakeViewer() { }, cancelFlight() { cancelled++; }, }, - }; + }); } /** Yield enough turns for the director's pending awaits to advance. */ @@ -1776,7 +1777,7 @@ test('destroy drains cancelled LOAD work before its viewer can be discarded', as t.after(restore); let finishVisual; const dataManager = fakeDataManager(); - const viewer = { camera: { cancelFlight() {} } }; + const viewer = bindFakeCesiumCamera({ camera: { cancelFlight() {} } }); const director = new SceneDirector(viewer, { applyVisualState: () => new Promise((resolve) => { finishVisual = resolve; }), }, dataManager); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 2c59c7079..8b7bc5566 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -12,6 +12,7 @@ import { msg } from '../i18n/index.js'; import { useProviderSettingsStore } from '../ui/stores/providerSettings.js'; import { applicationPinia } from '../ui/vueRuntime.js'; import * as Cesium from 'cesium'; +import { cameraOf } from '../render/cesium/camera.js'; import { distance as chordM, toLonLatH } from '../geo/index.js'; import { CITY_POIS } from '../cityPois.js'; import { greatCircleKm } from '../geoDistance.js'; @@ -2654,14 +2655,18 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) { 'frame', 'frame_overhead', () => { - viewer.camera.flyToBoundingSphere(sphere, { - duration: 2.0, - offset: new Cesium.HeadingPitchRange( - viewer.camera.heading, - pitch, - sphere.radius * 2.4, - ), - }); + const camera = cameraOf(viewer); + camera.flyToBounds( + { center: sphere.center, radius: sphere.radius }, + { + durationS: 2.0, + offset: { + heading: camera.pose().heading, + pitch, + range: sphere.radius * 2.4, + }, + }, + ); let detectionEnabled = false; try {