From 0b1c5d0f231bbfc9b4e46baf4de4643a8d12db73 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 08:21:57 -0800 Subject: [PATCH] Fly the remaining flights through the camera port, on any viewer The embed view, the celestial-ring focus, the ground-guard lift, the Bhote Koshi approach and orbit, launch selection and replay flights, and the cockpit's view and takeover now drive the renderer's camera port. The contract gains a sineInOut easing, pinned by a conformance trace. A viewer with no renderer bound, such as one a consumer of a public export brings, now gets a camera port too, as it already got an input port: cameraOf builds one over the viewer's own Cesium camera and keeps it for the viewer's life, until a renderer is bound. The Cesium adapter provides the factory as it loads; code that reaches Cesium takes cameraOf from the adapter so the factory comes with it, and findCamera answers null where a missing camera is not an error (HUD, share link, world focus, Recent Imagery's ZOOM IN). A test walks every package export and fails one that can reach the bridge without loading the factory. A destroyed viewer gets no camera and lets go of a built one, and the application renderer stays bound until its viewer is destroyed. --- scripts/package-boundaries.json | 116 +++++ src/app/embed.js | 27 +- src/app/embed.test.mjs | 5 +- src/cameraGroundGuard.js | 21 +- src/cameraGroundGuard.test.mjs | 116 +++-- src/cameraHandoff.test.mjs | 466 ++++++++++++++------- src/celestialRing.js | 32 +- src/data/bhoteKoshiLocator.js | 90 ++-- src/data/bhoteKoshiLocator.test.mjs | 11 +- src/hud.js | 4 +- src/layers/alpr/presentation.js | 2 +- src/layers/awareness/focus.js | 2 +- src/layers/cctv/navigation.js | 2 +- src/layers/cyclones/index.js | 2 +- src/layers/installations/controls.js | 2 +- src/layers/launches/policyHelpers.js | 2 +- src/layers/launches/replay.js | 25 +- src/layers/launches/selection.js | 53 ++- src/layers/radio/navigation.js | 2 +- src/layers/recentImagery/index.js | 12 +- src/layers/recentImagery/rendering.js | 3 + src/layers/streetLevel/cameraFraming.js | 2 +- src/layers/submarineCables/interaction.js | 2 +- src/layers/weather/index.js | 2 +- src/locations.js | 2 +- src/render/cesium/appRenderer.js | 4 +- src/render/cesium/camera.js | 26 +- src/render/cesium/cameraGoldens.js | 1 + src/render/cesium/cameraPort.test.mjs | 24 ++ src/render/cesium/common.js | 15 + src/render/cesium/ports.js | 17 +- src/render/conformance/cameraScripts.js | 23 + src/render/conformance/goldens/camera.json | 104 +++++ src/render/contract/camera.d.ts | 7 +- src/render/rendererOf.js | 65 ++- src/render/rendererOf.test.mjs | 57 ++- src/render/stub/camera.js | 1 + src/render/unboundCamera.test.mjs | 146 +++++++ src/sharelink.js | 4 +- src/ui/applicationShell.js | 2 +- src/ui/cockpitCamera.js | 3 +- src/ui/cockpitPresence.test.mjs | 2 + src/ui/cockpitTrackingController.js | 3 +- src/worldFocus.js | 4 +- 44 files changed, 1167 insertions(+), 344 deletions(-) create mode 100644 src/render/unboundCamera.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e065676c4..fd16e3101 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -848,11 +848,22 @@ "src/data/distanceFormat.js", "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", + "src/geo/index.js", "src/ui/cockpitBriefPages.js", "src/ui/cockpitContextState.js", "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", "src/ui/cockpitCamera.js", + "src/render/cesium/camera.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/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", @@ -968,7 +979,11 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/render/cesium/camera.js", "src/render/rendererOf.js", + "src/render/camera/events.js", + "src/render/cesium/internals.js", + "src/render/cesium/common.js", "src/ui/localePicker.js", "src/ui/cyberSonarControls.js", "src/ui/locationNavigation.js", @@ -1711,10 +1726,12 @@ "src/layers/launches/camera.js", "src/layers/launches/controls.js", "src/layers/launches/index.js", + "src/render/cesium/camera.js", "src/layers/launches/ingestion.js", "src/layers/launches/launchPad.js", "src/layers/launches/lifecycle.js", "src/layers/launches/model.js", + "src/geo/index.js", "src/layers/launches/orbitRendering.js", "src/layers/launches/overlays.js", "src/layers/launches/panel.js", @@ -1722,6 +1739,14 @@ "src/layers/launches/policy.js", "src/layers/launches/policyHelpers.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/layers/launches/rendering.js", "src/layers/launches/replay.js", "src/layers/launches/selection.js", @@ -1761,7 +1786,17 @@ "src/data/militaryInstallationData.js", "src/data/installationNames.js", "src/layers/installations/controls.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/layers/installations/index.js", "src/layers/installations/ingestion.js", "src/layers/installations/lifecycle.js", @@ -1825,11 +1860,21 @@ "src/cockpitMath.js", "src/data/iconOrientation.js", "src/data/militaryAwarenessEngine.js", + "src/geo/index.js", "src/data/installationNames.js", "src/layers/awareness/controls.js", "src/layers/awareness/dependencies.js", "src/layers/awareness/focus.js", + "src/render/cesium/camera.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/layers/awareness/history.js", "src/layers/awareness/index.js", "src/layers/awareness/ingestion.js", @@ -1987,16 +2032,26 @@ "src/layers/cctv/geometryQueue.js", "src/layers/cctv/ground.js", "src/layers/cctv/headingConfidence.js", + "src/geo/index.js", "src/layers/cctv/health.js", "src/layers/cctv/hover.js", "src/layers/cctv/index.js", + "src/geo/frames.js", "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", "src/geoDistance.js", "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", + "src/render/cesium/camera.js", "src/render/rendererOf.js", + "src/render/camera/events.js", + "src/geo/celestial.js", + "src/geo/quaternion.js", + "src/geo/ellipsoid.js", + "src/geo/vector.js", + "src/render/cesium/common.js", + "src/render/cesium/internals.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", "src/layers/cctv/projection.js", @@ -2052,11 +2107,21 @@ ], "modules": [ "src/layers/streetLevel/index.js", + "src/geo/index.js", "src/layers/streetLevel/credits.js", "src/layers/streetLevel/providers/mapillary/index.js", "src/layers/streetLevel/viewerHost.js", "src/layers/streetLevel/cameraFraming.js", + "src/render/cesium/camera.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/layers/streetLevel/marker.js", "src/layers/streetLevel/view.js", "src/layers/streetLevel/uiState.js", @@ -2129,10 +2194,20 @@ "modules": [ "src/data/headingDegrees.js", "src/layers/alpr/index.js", + "src/geo/index.js", "src/layers/alpr/model.js", "src/layers/alpr/policy.js", "src/layers/alpr/presentation.js", + "src/render/cesium/camera.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/common.js", + "src/render/cesium/internals.js", "src/layers/alpr/visuals.js", "src/layers/alpr/overlay.js", "src/layers/alpr/markerState.js", @@ -2183,6 +2258,7 @@ "src/data/radioCountry.js", "src/layers/radio/catalogModel.js", "src/layers/radio/categories.js", + "src/geo/index.js", "src/layers/radio/clustering.js", "src/layers/radio/controls.js", "src/layers/radio/index.js", @@ -2192,7 +2268,15 @@ "src/layers/radio/lifecycle.js", "src/layers/radio/model.js", "src/layers/radio/navigation.js", + "src/render/cesium/camera.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/common.js", "src/layers/radio/playback.js", "src/layers/radio/policy.js", "src/layers/radio/presentation.js", @@ -2237,8 +2321,18 @@ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", "src/layers/submarineCables/ingestion.js", + "src/geo/index.js", "src/layers/submarineCables/interaction.js", + "src/render/cesium/camera.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/layers/submarineCables/pickState.js", "src/data/entityRecords.js", "src/layers/submarineCables/lifecycle.js", @@ -3481,6 +3575,7 @@ "src/maps/imageryComparison.js", "src/data/bhoteKoshiEmbeddedMedia.js", "src/data/bhoteKoshiLocator.js", + "src/render/cesium/camera.js", "src/data/bhoteKoshiIncidentPlaces.js", "src/data/bhoteKoshiFloodPath.js", "src/data/bhoteKoshiShotPaths.js", @@ -3819,6 +3914,8 @@ "src/layers/vessels/tracking.js", "src/locations.js", "src/render/rendererOf.js", + "src/render/camera/events.js", + "src/render/cesium/common.js", "src/cityPois.js", "src/navigationPolicy.js", "src/overlays/worldOverlay.js", @@ -4121,7 +4218,16 @@ "src/keylessGeocoder.js", "src/layers/firms/anchors.js", "src/locations.js", + "src/render/cesium/camera.js", "src/render/rendererOf.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/cesium/common.js", "src/cityPois.js", "src/cameraGroundGuard.js", "src/search/alpsGeocoder.js", @@ -4713,6 +4819,15 @@ "src/data/headingDegrees.js", "src/cyberSonar.js", "src/celestialRing.js", + "src/render/cesium/camera.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/common.js", "src/data/approxDistance.js", "src/data/cameraSensitivity.js", "src/data/contactPlayback.js", @@ -4733,6 +4848,7 @@ "src/data/spriteOrder.js", "src/data/terrainHeights.js", "src/data/transit.js", + "src/geo/index.js", "src/data/transitFeeds.js", "src/data/transitIcons.js", "src/data/transitPresetStyle.js", diff --git a/src/app/embed.js b/src/app/embed.js index 21e685a91..897453f40 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -13,6 +13,7 @@ import * as Cesium from 'cesium'; import { announceNavigationAuthority } from '../navigationPolicy.js'; +import { cameraOf } from '../render/cesium/camera.js'; import { annotationsFromParams, createView } from '../view/index.js'; const EMBED_VIEW_MESSAGE = 'gev:view'; @@ -147,21 +148,19 @@ const delay = (ms, signal) => function flyTo(viewer, camera) { announceNavigationAuthority('embed-view'); return new Promise((resolve) => { - viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees( - camera.lon, - camera.lat, - camera.altitude_m, - ), - orientation: { - heading: Cesium.Math.toRadians(camera.heading_deg), - pitch: Cesium.Math.toRadians(camera.pitch_deg), - roll: 0, + cameraOf(viewer).flyTo( + { lon: camera.lon, lat: camera.lat, height: camera.altitude_m }, + { + orientation: { + heading: Cesium.Math.toRadians(camera.heading_deg), + pitch: Cesium.Math.toRadians(camera.pitch_deg), + roll: 0, + }, + durationS: FLIGHT_SECONDS, + onComplete: () => resolve(true), + onCancel: () => resolve(false), }, - duration: FLIGHT_SECONDS, - complete: () => resolve(true), - cancel: () => resolve(false), - }); + ); }); } diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index 5fa949342..3f48d2e97 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -1,3 +1,4 @@ +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; import assert from 'node:assert/strict'; import test from 'node:test'; import { @@ -12,7 +13,7 @@ import { createView } from '../view/index.js'; const fakeViewer = () => { const flights = []; - return { + return bindFakeCesiumCamera({ flights, camera: { flyTo(options) { @@ -20,7 +21,7 @@ const fakeViewer = () => { options.complete(); }, }, - }; + }); }; const fakeLayers = (enabled) => ({ getAll: () => diff --git a/src/cameraGroundGuard.js b/src/cameraGroundGuard.js index 6ad64622b..225af25ec 100644 --- a/src/cameraGroundGuard.js +++ b/src/cameraGroundGuard.js @@ -17,6 +17,7 @@ import * as Cesium from 'cesium'; import { ownCameraArrival } from './data/cameraArrival.js'; import { heightsFor } from './queries/heights.js'; +import { cameraOf } from './render/cesium/camera.js'; import { cameraFlightActive } from './render/cesium/internals.js'; /** Minimum eye height above the rendered surface for a usable view. */ @@ -196,20 +197,22 @@ export function guardCameraAboveGround(viewer, at, options = {}) { ); if (deficit > 0) { stop(); - camera.flyTo({ - destination: Cesium.Cartesian3.fromRadians( + cameraOf(viewer).flyTo( + Cesium.Cartesian3.fromRadians( carto.longitude, carto.latitude, carto.height + deficit, ), - orientation: { - heading: camera.heading, - pitch: camera.pitch, - roll: camera.roll, + { + orientation: { + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + }, + durationS: 0.9, + easing: 'cubicInOut', }, - duration: 0.9, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }); + ); try { onLift?.(deficit); } catch { diff --git a/src/cameraGroundGuard.test.mjs b/src/cameraGroundGuard.test.mjs index 7303cbad6..0b994102e 100644 --- a/src/cameraGroundGuard.test.mjs +++ b/src/cameraGroundGuard.test.mjs @@ -3,6 +3,7 @@ // Arrival framing is computed before the destination's tiles exist, so it // works from a predicted ground height. The guard measures the real surface // after arrival and lifts the eye; these tests pin when it acts. +import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; @@ -49,8 +50,12 @@ test('the clearance frames a subject without turning into an overflight', () => function fakeViewer({ cameraHeightM, surfaceM }) { const flights = []; - const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM); - return { + const position = Cesium.Cartographic.fromDegrees( + -97.7603, + 30.3141, + cameraHeightM, + ); + return bindFakeCesiumCamera({ flights, scene: { canvas: null, @@ -71,7 +76,7 @@ function fakeViewer({ cameraHeightM, surfaceM }) { this._currentFlight = {}; }, }, - }; + }); } test('the guard lifts a buried arrival once the surface answers', async () => { @@ -111,7 +116,11 @@ test('the guard waits for streaming tiles and yields to a newer arrival', async test('the guard leaves a clear view untouched', async () => { const viewer = fakeViewer({ cameraHeightM: 400, surfaceM: 171 }); - guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1 }); + guardCameraAboveGround( + viewer, + { lat: 30.3125, lon: -97.765 }, + { intervalMs: 1 }, + ); await new Promise((resolve) => setTimeout(resolve, 20)); assert.equal(viewer.flights.length, 0); }); @@ -142,27 +151,58 @@ function handoffFixture(t) { const originalFly = viewer.camera.flyTo; const navigation = new NavigationController({ viewer, - tracking: Object.fromEntries(['flightsLayer', 'militaryFlightsLayer', - 'satellitesLayer', 'aisLiveVesselsLayer', 'militaryAwarenessLayer', - 'rocketLaunchesLayer'].map((key) => [key, {}])), - cancelOrientation() {}, clearLocation() {}, cancelShareSelection() {}, - getDataManager() {}, interruptCameraMotion() {}, stopOrbit() {}, - isCockpitActive: () => false, showToast() {}, + tracking: Object.fromEntries( + [ + 'flightsLayer', + 'militaryFlightsLayer', + 'satellitesLayer', + 'aisLiveVesselsLayer', + 'militaryAwarenessLayer', + 'rocketLaunchesLayer', + ].map((key) => [key, {}]), + ), + cancelOrientation() {}, + clearLocation() {}, + cancelShareSelection() {}, + getDataManager() {}, + interruptCameraMotion() {}, + stopOrbit() {}, + isCockpitActive: () => false, + showToast() {}, }); guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 }); return { viewer, navigation, originalFly }; } -for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI', - 'flyTo', 'flyToBoundingSphere', 'setView', 'layer-disable', 'layer-destroy', 'app-stop']) { +for (const handoff of [ + 'Director', + 'tracking', + 'follow', + 'keyboard', + 'UI', + 'flyTo', + 'flyToBoundingSphere', + 'setView', + 'layer-disable', + 'layer-destroy', + 'app-stop', +]) { test(`arrival guard relinquishes and removes hooks on ${handoff}`, async (t) => { const { viewer, navigation, originalFly } = handoffFixture(t); - const methods = ['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight'] - .map((name) => viewer.camera[name]); + const methods = [ + 'flyTo', + 'flyToBoundingSphere', + 'setView', + 'lookAt', + 'cancelFlight', + ].map((name) => viewer.camera[name]); if (handoff === 'Director') { SceneDirector.prototype._claimCameraOwnership.call({ - viewer, styleManager: { runImmediateNavigation: (noun, fn) => - navigation._runExplicitNavigation(noun, fn) }, + viewer, + styleManager: { + runImmediateNavigation: (noun, fn) => + navigation._runExplicitNavigation(noun, fn), + }, }); } else if (handoff === 'tracking') { viewer.trackedEntityChanged.raiseEvent({ id: 'contact' }); @@ -173,19 +213,31 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI', } else if (handoff === 'app-stop') navigation.stop(); else if (handoff.startsWith('layer-')) { const lifecycle = new LayerLifecycle(viewer); - lifecycle.register({ id: 'test', init() {}, enable() {}, disable() {}, destroy() {} }); + lifecycle.register({ + id: 'test', + init() {}, + enable() {}, + disable() {}, + destroy() {}, + }); await lifecycle.setEnabled('test', true); - if (handoff === 'layer-disable') await lifecycle.setEnabled('test', false); + if (handoff === 'layer-disable') + await lifecycle.setEnabled('test', false); else await lifecycle.destroyLayer('test'); } else viewer.camera[handoff]({}); // A direct flyTo is itself one recorded flight; the guard adds none. const priorFlights = viewer.flights.length; t.mock.timers.tick(10000); - assert.equal(viewer.flights.length, priorFlights, 'the old probe cannot fly'); + assert.equal( + viewer.flights.length, + priorFlights, + 'the old probe cannot fly', + ); assert.equal(viewer.camera.flyTo, originalFly); assert.deepEqual( - ['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight'] - .map((name) => viewer.camera[name]), + ['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight'].map( + (name) => viewer.camera[name], + ), methods, 'the guard never replaces camera methods', ); @@ -196,7 +248,11 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI', test('the guard yields to a camera moved without any camera method', async () => { const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 }); - guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 5 }); + guardCameraAboveGround( + viewer, + { lat: 30.3125, lon: -97.765 }, + { intervalMs: 5 }, + ); // A cockpit or follow frame writes the pose directly. viewer.camera.directionWC = new Cesium.Cartesian3(1, 0, 0); await new Promise((resolve) => setTimeout(resolve, 20)); @@ -205,20 +261,30 @@ test('the guard yields to a camera moved without any camera method', async () => test('Cesium lifting a buried eye straight up is not another owner', async () => { const viewer = fakeViewer({ cameraHeightM: 104, surfaceM: 171 }); - guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 5 }); + guardCameraAboveGround( + viewer, + { lat: 30.3125, lon: -97.765 }, + { intervalMs: 5 }, + ); // Terrain collision raises the eye to just above the loaded ground. const raised = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 172); viewer.camera.positionCartographic = raised; viewer.camera.positionWC = Cesium.Cartographic.toCartesian(raised); await new Promise((resolve) => setTimeout(resolve, 30)); assert.equal(viewer.flights.length, 1, 'the guard still lifts the eye'); - const lifted = Cesium.Cartographic.fromCartesian(viewer.flights[0].destination); + const lifted = Cesium.Cartographic.fromCartesian( + viewer.flights[0].destination, + ); assert.ok(Math.abs(lifted.height - (171 + MIN_EYE_CLEARANCE_M)) < 0.5); }); test('another owner lowering the eye is not corrected', async () => { const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 }); - guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 5 }); + guardCameraAboveGround( + viewer, + { lat: 30.3125, lon: -97.765 }, + { intervalMs: 5 }, + ); const lowered = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 150); viewer.camera.positionCartographic = lowered; viewer.camera.positionWC = Cesium.Cartographic.toCartesian(lowered); diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index f8c26d94a..b8171dd72 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -1,7 +1,13 @@ import { LayerBindings } from './ui/layerBindings.js'; -import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; +import { + readShellSource, + shellMethod, +} from './testSupport/readShellSource.mjs'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; -import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js'; +import { + enter as cockpitEnter, + navigateContext, +} from './ui/cockpitTrackingController.js'; import { CockpitViewController } from './ui/cockpitController.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -11,11 +17,24 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ui = readShellSource(); -const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js')); -const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js')); -const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8'); -const cameraVerbs = fs.readFileSync(path.join(ROOT, 'src', 'cameraVerbs.js'), 'utf8'); -const cockpitTracking = fs.readFileSync(path.join(ROOT, 'src', 'cockpitTracking.js'), 'utf8'); +const firms = readLayerSource( + path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'), +); +const vessels = readLayerSource( + path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'), +); +const voice = fs.readFileSync( + path.join(ROOT, 'src', 'voice', 'gevActions.js'), + 'utf8', +); +const cameraVerbs = fs.readFileSync( + path.join(ROOT, 'src', 'cameraVerbs.js'), + 'utf8', +); +const cockpitTracking = fs.readFileSync( + path.join(ROOT, 'src', 'cockpitTracking.js'), + 'utf8', +); function body(source, pattern, label) { const name = pattern.source.match(/^(\w+)/)?.[1]; @@ -31,8 +50,14 @@ function body(source, pattern, label) { function ordered(source, needles, label) { let previous = -1; for (const needle of needles) { - const match = needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null; - const index = needle instanceof RegExp ? (match ? previous + 1 + match.index : -1) : source.indexOf(needle, previous + 1); + const match = + needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null; + const index = + needle instanceof RegExp + ? match + ? previous + 1 + match.index + : -1 + : source.indexOf(needle, previous + 1); assert.ok(index >= 0, `${label}: missing ${needle}`); assert.ok(index > previous, `${label}: ${needle} is out of order`); previous = index; @@ -41,12 +66,16 @@ function ordered(source, needles, label) { test('Cockpit takeover invalidates deferred work before camera cancellation', () => { const enter = cockpitEnter.toString(); - ordered(enter, [ - 'if (!info || !entity?.position) return false;', - 'this.onCameraTakeover?.();', - 'this.viewer.camera.cancelFlight();', - 'this.viewer.trackedEntity = undefined;', - ], 'Cockpit takeover'); + ordered( + enter, + [ + 'if (!info || !entity?.position) return false;', + 'this.onCameraTakeover?.();', + 'cameraOf(this.viewer).cancelFlight();', + 'this.viewer.trackedEntity = undefined;', + ], + 'Cockpit takeover', + ); assert.match( ui, /onCameraTakeover: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\),/, @@ -80,11 +109,23 @@ test('navigation clears dormant tracker IDs without aborting unrelated layer res ); assert.doesNotMatch(stamp, /cancelPendingRestores\(\)/); assert.match(stamp, /flightsLayer\.cancelPendingTrackingRestore\?\.\(\)/); - assert.match(stamp, /militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/); + assert.match( + stamp, + /militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/, + ); assert.match(stamp, /satellitesLayer\.cancelPendingTrackingRestore\?\.\(\)/); - assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/); - assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/); - assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/); + assert.match( + stamp, + /if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/, + ); + assert.match( + stamp, + /if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/, + ); + assert.match( + stamp, + /if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/, + ); }); test('voice Cockpit entry reaches the camera only through stamping seams', () => { @@ -104,10 +145,14 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () => /export function enterCockpitWithTracking\(\{[\s\S]*?\n\}\) \{([\s\S]*?)\n\}\n/, 'Cockpit entry transaction', ); - ordered(transaction, [ - /if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/, - 'if (!entryError) entered = Boolean(cockpitView.enter());', - ], 'Cockpit entry transaction'); + ordered( + transaction, + [ + /if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/, + 'if (!entryError) entered = Boolean(cockpitView.enter());', + ], + 'Cockpit entry transaction', + ); // No direct camera control: every mutation goes through a layer tracker or // the cockpit controller, both of which stamp. assert.doesNotMatch(transaction, /\b(?:viewer\.)?camera\s*(?:\.|\[|=)/); @@ -146,27 +191,41 @@ test('voice Cockpit next/previous shares the manual Context navigation path', () /this\._listen\(this\.contextNext, 'click', \(\) =>\s*this\.navigateContext\(1, \{ origin: 'user' \}\),?\s*\);/, ); const funnel = navigateContext.toString(); - assert.match(funnel, /const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/); - ordered(funnel, [ - "const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';", - 'const navigationOptions = wasActive', - 'militaryAwarenessLayer?.[method]?.(navigationOptions)', - 'this._adoptTrackedEntity(performance.now());', - ], 'Cockpit Context navigation funnel'); + assert.match( + funnel, + /const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/, + ); + ordered( + funnel, + [ + "const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';", + 'const navigationOptions = wasActive', + 'militaryAwarenessLayer?.[method]?.(navigationOptions)', + 'this._adoptTrackedEntity(performance.now());', + ], + 'Cockpit Context navigation funnel', + ); const navigate = body( ui, /if \(normalized === 'next' \|\| normalized === 'previous'\) \{([\s\S]*?)\n \}/, 'controlCockpit navigation branch', ); - ordered(navigate, [ - 'this.cockpitView.navigateContext(', - "normalized === 'next' ? 1 : -1,", - ], 'controlCockpit navigation branch'); + ordered( + navigate, + ['this.cockpitView.navigateContext(', "normalized === 'next' ? 1 : -1,"], + 'controlCockpit navigation branch', + ); // No private camera route around the awareness layer. - assert.doesNotMatch(navigate, /flyTo|camera|trackById|_runExplicitNavigation/); + assert.doesNotMatch( + navigate, + /flyTo|camera|trackById|_runExplicitNavigation/, + ); // An exhausted cohort is an honest failure, not a silent success. assert.match(navigate, /ok: changed,/); - assert.match(navigate, /error: changed \? null : 'No further context target was available',/); + assert.match( + navigate, + /error: changed \? null : 'No further context target was available',/, + ); }); test('accepted navigation releases through PR15-aware ownership before flight', () => { @@ -175,37 +234,69 @@ test('accepted navigation releases through PR15-aware ownership before flight', /_runExplicitNavigation\(noun, navigate, releaseOptions = undefined\) \{([\s\S]*?)\n \}/, 'explicit navigation', ); - ordered(run, [ - 'cockpitActive: this.isCockpitActive()', - 'stamp: () => this._stampNavigation()', - 'release: () => this._releaseFollowCamera(releaseOptions)', - 'navigate,', - ], 'explicit navigation'); + ordered( + run, + [ + 'cockpitActive: this.isCockpitActive()', + 'stamp: () => this._stampNavigation()', + 'release: () => this._releaseFollowCamera(releaseOptions)', + 'navigate,', + ], + 'explicit navigation', + ); const release = body( ui, /_releaseFollowCamera\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, 'follow release', ); - ordered(release, [ - 'origin: trackingOrigin', - 'satellitesLayer.stopTracking?.({ origin: trackingOrigin })', - 'rocketLaunchesLayer.releaseCameraOwnership?.()', - 'this.viewer.trackedEntity = undefined;', - "interruptCameraMotion('explicit-navigation')", - 'this.viewer.camera.cancelFlight();', - 'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);', - ], 'follow release'); - assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/); - assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/); + ordered( + release, + [ + 'origin: trackingOrigin', + 'satellitesLayer.stopTracking?.({ origin: trackingOrigin })', + 'rocketLaunchesLayer.releaseCameraOwnership?.()', + 'this.viewer.trackedEntity = undefined;', + "interruptCameraMotion('explicit-navigation')", + 'this.viewer.camera.cancelFlight();', + 'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);', + ], + 'follow release', + ); + assert.match( + release, + /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/, + ); + assert.match( + release, + /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/, + ); }); test('validated voice camera destinations share the UI navigation authority facade', () => { - assert.match(ui, /runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/); - assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/); - assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/); - assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/); - assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/); - assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/); + assert.match( + ui, + /runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/, + ); + assert.match( + voice, + /runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/, + ); + assert.match( + voice, + /runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/, + ); + assert.match( + voice, + /runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/, + ); + assert.match( + voice, + /runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/, + ); + assert.match( + voice, + /runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/, + ); const trackedVoice = voice.slice( voice.indexOf('async function trackEntity'), voice.indexOf('async function frameOverhead'), @@ -222,47 +313,80 @@ test('validated voice camera destinations share the UI navigation authority faca /export function moveCamera\(args = \{\}, runNavigation = null\) \{([\s\S]*?)\n\}/, 'move camera', ); - ordered(move, [ - "if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))", - 'const start = () => {', - /const preserveCameraFlight =\s*motion === 'orbit'/, - 'runNavigation(start, { preserveCameraFlight })', - ], 'move validation before handoff'); + ordered( + move, + [ + "if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))", + 'const start = () => {', + /const preserveCameraFlight =\s*motion === 'orbit'/, + 'runNavigation(start, { preserveCameraFlight })', + ], + 'move validation before handoff', + ); const route = body( cameraVerbs, /export function flyRoute\(\s*annoList,\s*args = \{\},\s*floorFn = null,\s*runNavigation = null,\s*warmFn = null,?\s*\) \{([\s\S]*?)\n\}/, 'fly route', ); - ordered(route, [ - 'if (!routes.length)', - 'const pts = route.path.map', - 'const start = () => {', - /return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/, - ], 'route validation before handoff'); + ordered( + route, + [ + 'if (!routes.length)', + 'const pts = route.path.map', + 'const start = () => {', + /return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/, + ], + 'route validation before handoff', + ); // The corridor warm is injected the same way the floor READ is — the dolly // never reaches into the data layer itself, and the voice dispatch is the one // place that binds both. - assert.match(voice, /\(lat, lon\) => floorServices\.cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => floorServices\.warmGroundFloor\(cells\),/); + assert.match( + voice, + /\(lat, lon\) => floorServices\.cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => floorServices\.warmGroundFloor\(cells\),/, + ); }); test('deferred search releases only after its final authority check', () => { - const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8'); - ordered(search, [ - 'const authority = this.begin();', - 'this.onStart?.(authority);', - 'await this.search(query', - 'beforeFly: () => current() && this.beforeFly(authority)', - 'if (!current() || controller.signal.aborted) return;', - 'destination?.cancelled', - 'finally', - 'this.onSettled?.(authority)', - ], 'deferred search component'); - assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/); - assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/); - assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/); - assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/); - assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/); + const search = fs.readFileSync( + path.join(ROOT, 'src', 'ui', 'locationSearch.js'), + 'utf8', + ); + ordered( + search, + [ + 'const authority = this.begin();', + 'this.onStart?.(authority);', + 'await this.search(query', + 'beforeFly: () => current() && this.beforeFly(authority)', + 'if (!current() || controller.signal.aborted) return;', + 'destination?.cancelled', + 'finally', + 'this.onSettled?.(authority)', + ], + 'deferred search component', + ); + assert.match( + ui, + /begin: \(\) => this\._beginDeferredNavigation\('location'\)/, + ); + assert.match( + ui, + /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/, + ); + assert.match( + ui, + /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/, + ); + assert.match( + ui, + /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/, + ); + assert.doesNotMatch( + search.slice(0, search.indexOf('await this.search')), + /_releaseFollowCamera/, + ); }); test('a direct globe gesture retires delayed camera and selection restore only', () => { @@ -287,77 +411,115 @@ test('a direct globe gesture retires delayed camera and selection restore only', /_stampNavigation\(\{ cancelPendingSelection = true[^)]*\} = \{\}\) \{([\s\S]*?)\n \}/, 'navigation stamp', ); - assert.match(stamp, /if \(cancelPendingSelection\) \{[\s\S]*?cancelPendingTrackingRestore/); + assert.match( + stamp, + /if \(cancelPendingSelection\) \{[\s\S]*?cancelPendingTrackingRestore/, + ); assert.doesNotMatch(stamp, /cancelPendingRestores\(\)/); }); test('newer navigation, reset, Cockpit, and teardown share one generation', () => { assert.equal((ui.match(/_navigationGeneration \+= 1/g) || []).length, 1); const reset = body(ui, /resetToGlobeView\(\) \{([\s\S]*?)\n \}/, 'reset'); - ordered(reset, [ - 'if (this._globeResetPromise) return this._globeResetPromise;', - 'this._stampNavigation();', - "interruptCameraMotion('reset-globe')", - ], 'reset supersession'); + ordered( + reset, + [ + 'if (this._globeResetPromise) return this._globeResetPromise;', + 'this._stampNavigation();', + "interruptCameraMotion('reset-globe')", + ], + 'reset supersession', + ); const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose'); - ordered(dispose, [ - 'this._disposed = true;', - 'this._layerBindings.stop();', - 'this._navigation.destroy();', - 'await this._contextControls.restoreForDisposal();', - ], 'dispose invalidation'); + ordered( + dispose, + [ + 'this._disposed = true;', + 'this._layerBindings.stop();', + 'this._navigation.destroy();', + 'await this._contextControls.restoreForDisposal();', + ], + 'dispose invalidation', + ); assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\)/); }); test('teardown synchronously closes immediate camera entry points', () => { const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose'); - ordered(dispose, [ - 'this._disposed = true;', - 'this._layerBindings.stop();', - 'await this._contextControls.restoreForDisposal();', - ], 'synchronous teardown barrier'); - ordered(LayerBindings.prototype.stop.toString(), [ - 'this._disposed = true;', - 'this._removeCctvRequestFocusListener?.();', - 'this._removeWorldRequestFocusListener?.();', - 'this._navigationOwnerChangedRemover?.();', - ], 'camera bindings close synchronously'); + ordered( + dispose, + [ + 'this._disposed = true;', + 'this._layerBindings.stop();', + 'await this._contextControls.restoreForDisposal();', + ], + 'synchronous teardown barrier', + ); + ordered( + LayerBindings.prototype.stop.toString(), + [ + 'this._disposed = true;', + 'this._removeCctvRequestFocusListener?.();', + 'this._removeWorldRequestFocusListener?.();', + 'this._navigationOwnerChangedRemover?.();', + ], + 'camera bindings close synchronously', + ); const navigation = body( ui, /_runExplicitNavigation\(noun, navigate, releaseOptions = undefined\) \{([\s\S]*?)\n \}/, 'explicit navigation', ); - ordered(navigation, [ - 'disposed: this._disposed', - 'release: () => this._releaseFollowCamera(releaseOptions)', - 'navigate,', - ], 'disposed navigation guard'); + ordered( + navigation, + [ + 'disposed: this._disposed', + 'release: () => this._releaseFollowCamera(releaseOptions)', + 'navigate,', + ], + 'disposed navigation guard', + ); const cctvFocus = body( ui, /_runExplicitCctvFocus\(activate, focus\) \{([\s\S]*?)\n \}/, 'explicit CCTV focus', ); - ordered(cctvFocus, [ - 'if (this._disposed) return false;', - 'const cameraId = activate();', - ], 'disposed CCTV activation guard'); + ordered( + cctvFocus, + ['if (this._disposed) return false;', 'const cameraId = activate();'], + 'disposed CCTV activation guard', + ); }); test('teardown refuses deferred location work before geocoding begins', () => { - const deferred = body(ui, /_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/, 'deferred navigation'); + const deferred = body( + ui, + /_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/, + 'deferred navigation', + ); assert.match(deferred, /disposed: this\._disposed/); - const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8'); - ordered(search, [ - 'if (!query || this.destroyed) return;', - 'const authority = this.begin();', - 'if (authority === false)', - 'this.input?.blur();', - 'this.onStart?.(authority);', - 'await this.search(query', - ], 'disposed search refusal'); - assert.match(search, /if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/); + const search = fs.readFileSync( + path.join(ROOT, 'src', 'ui', 'locationSearch.js'), + 'utf8', + ); + ordered( + search, + [ + 'if (!query || this.destroyed) return;', + 'const authority = this.begin();', + 'if (authority === false)', + 'this.input?.blur();', + 'this.onStart?.(authority);', + 'await this.search(query', + ], + 'disposed search refusal', + ); + assert.match( + search, + /if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/, + ); }); test('refused canned destinations commit no location or POI state', () => { @@ -366,11 +528,15 @@ test('refused canned destinations commit no location or POI state', () => { ['poi', /_onPoiClick\(cityId, poiIndex\) \{([\s\S]*?)\n \}/], ]) { const handler = body(ui, pattern, name); - ordered(handler, [ - 'this._flyWithTransition(', - 'if (result === false) return;', - 'this._setActiveLocation(cityId);', - ], name); + ordered( + handler, + [ + 'this._flyWithTransition(', + 'if (result === false) return;', + 'this._setActiveLocation(cityId);', + ], + name, + ); } }); @@ -392,11 +558,15 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () => /inputFor\(viewer\)\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/, 'vessel click', ); - ordered(vesselClick, [ - "isOwnedByOtherLayer('ais-live-vessels', pickedId)", - '_vesselOverlayHost.hitTest?.(', - 'selectAndFocusVessel(record)', - ], 'vessel sibling ownership'); + ordered( + vesselClick, + [ + "isOwnedByOtherLayer('ais-live-vessels', pickedId)", + '_vesselOverlayHost.hitTest?.(', + 'selectAndFocusVessel(record)', + ], + 'vessel sibling ownership', + ); const vesselFocus = body( vessels, /function selectAndFocusVessel\(record\) \{([\s\S]*?)\n \}/, @@ -408,9 +578,13 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () => /input\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/, 'fire click', ); - ordered(fireClick, [ - 'isOwnedByOtherLayer(id, pickedId)', - 'overlayHost.hitTest?.(', - 'selectAndFocusFire(carded)', - ], 'fire sibling ownership'); + ordered( + fireClick, + [ + 'isOwnedByOtherLayer(id, pickedId)', + 'overlayHost.hitTest?.(', + 'selectAndFocusFire(carded)', + ], + 'fire sibling ownership', + ); }); diff --git a/src/celestialRing.js b/src/celestialRing.js index c031593d4..4cb6edbd2 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { governorRequestRender } from './renderGovernor.js'; +import { cameraOf } from './render/cesium/camera.js'; /** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */ export const KEYHOLE_OUTER_RADIUS = 1.05; @@ -576,30 +577,33 @@ export class CelestialRing { const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4); const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); + const camera = cameraOf(this.viewer); this._focusInProgress = true; const finishFocus = () => { this._focusInProgress = false; this.viewer.scene.requestRender?.(); }; - this.viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromRadians( + camera.flyTo( + Cesium.Cartesian3.fromRadians( cartographic.longitude, cartographic.latitude, altitude, ), - orientation: { - heading: this.viewer.camera.heading, - pitch: -Cesium.Math.PI_OVER_TWO, - roll: 0, + { + orientation: { + heading: camera.pose().heading, + pitch: -Cesium.Math.PI_OVER_TWO, + roll: 0, + }, + durationS: duration, + easing: 'cubicInOut', + onComplete: finishFocus, + onCancel: () => { + finishFocus(); + if (!this.isGlobeFullyVisible()) this._autoDisable(); + }, }, - duration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - complete: finishFocus, - cancel: () => { - finishFocus(); - if (!this.isGlobeFullyVisible()) this._autoDisable(); - }, - }); + ); return true; } diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index 43b685a54..e504375e8 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -4,6 +4,7 @@ import { setOverlayEntries, } from '../overlays/worldOverlay.js'; import { governorRequestRender } from '../renderGovernor.js'; +import { cameraOf } from '../render/cesium/camera.js'; import { BHOTE_KOSHI_LOCATOR_CREDIT, registerDynamicCredit, @@ -620,6 +621,7 @@ export function createBhoteKoshiLocatorLayer({ requestRender('bhote-koshi-trigger-record-reveal'); const camera = _viewer?.camera; if (!camera?.flyTo) return; + const port = cameraOf(_viewer); const generation = _generation; _triggerApproachInFlight = true; const finish = () => { @@ -627,50 +629,54 @@ export function createBhoteKoshiLocatorLayer({ attempt.phase = 'finished'; _triggerApproachInFlight = false; }; - camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees( - TRIGGER_APPROACH_CAMERA.lon, - TRIGGER_APPROACH_CAMERA.lat, - TRIGGER_APPROACH_CAMERA.alt, - ), - orientation: { - heading: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.heading), - pitch: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.pitch), - roll: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.roll), + port.flyTo( + { + lon: TRIGGER_APPROACH_CAMERA.lon, + lat: TRIGGER_APPROACH_CAMERA.lat, + height: TRIGGER_APPROACH_CAMERA.alt, }, - duration: TRIGGER_APPROACH_DURATION_SEC, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - complete: () => { - if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying') - return; - if ( - !_enabled || - generation !== _generation || - _presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION - ) { - finish(); - return; - } - attempt.phase = 'orbiting'; - camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees( - TRIGGER_ORBIT_CAMERA.lon, - TRIGGER_ORBIT_CAMERA.lat, - TRIGGER_ORBIT_CAMERA.alt, - ), - orientation: { - heading: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.heading), - pitch: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.pitch), - roll: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.roll), - }, - duration: TRIGGER_ORBIT_DURATION_SEC, - easingFunction: Cesium.EasingFunction.SINUSOIDAL_IN_OUT, - complete: finish, - cancel: finish, - }); + { + orientation: { + heading: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.heading), + pitch: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.pitch), + roll: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.roll), + }, + durationS: TRIGGER_APPROACH_DURATION_SEC, + easing: 'cubicInOut', + onComplete: () => { + if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying') + return; + if ( + !_enabled || + generation !== _generation || + _presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION + ) { + finish(); + return; + } + attempt.phase = 'orbiting'; + port.flyTo( + { + lon: TRIGGER_ORBIT_CAMERA.lon, + lat: TRIGGER_ORBIT_CAMERA.lat, + height: TRIGGER_ORBIT_CAMERA.alt, + }, + { + orientation: { + heading: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.heading), + pitch: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.pitch), + roll: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.roll), + }, + durationS: TRIGGER_ORBIT_DURATION_SEC, + easing: 'sineInOut', + onComplete: finish, + onCancel: finish, + }, + ); + }, + onCancel: finish, }, - cancel: finish, - }); + ); } function armTriggerApproach() { diff --git a/src/data/bhoteKoshiLocator.test.mjs b/src/data/bhoteKoshiLocator.test.mjs index 605f8eaab..439d4fe7f 100644 --- a/src/data/bhoteKoshiLocator.test.mjs +++ b/src/data/bhoteKoshiLocator.test.mjs @@ -1,3 +1,4 @@ +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; import assert from 'node:assert/strict'; import test from 'node:test'; import { readFile } from 'node:fs/promises'; @@ -647,6 +648,7 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits' }, cancelFlight() {}, }; + bindFakeCesiumCamera(viewer); const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: async () => null, requestRender() {}, @@ -703,6 +705,9 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) { }, cancelFlight() {}, }; + bindFakeCesiumCamera(viewer); + // The renderer's camera port relays these; the layer's own come on top. + const portListeners = [moveStart, moveEnd].map((e) => e.numberOfListeners); const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: async () => null, overlayHost: overlayHostFixture(), @@ -745,8 +750,10 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) { const oldFlights = [...flights]; SceneDirector.prototype.stopScene.call(director); assert.equal(timers.size, 0, 'Stop cancels the actual delayed work'); - assert.equal(moveStart.numberOfListeners, 0); - assert.equal(moveEnd.numberOfListeners, 0); + assert.deepEqual( + [moveStart, moveEnd].map((e) => e.numberOfListeners), + portListeners, + ); callbacks[0](); moveEnd.raiseEvent(); for (const flight of oldFlights) flight.complete(); diff --git a/src/hud.js b/src/hud.js index b793410a0..f4c0f7a34 100644 --- a/src/hud.js +++ b/src/hud.js @@ -20,7 +20,7 @@ import { applicationServices } from './services/application.js'; */ import { toDegrees, toRadians } from './geo/index.js'; -import { rendererOf } from './render/rendererOf.js'; +import { findCamera } from './render/rendererOf.js'; import { greatCircleKm } from './geoDistance.js'; import { forward as toMGRS } from 'mgrs'; import { CITY_POIS } from './cityPois.js'; @@ -241,7 +241,7 @@ export class IntelHUD { basemapContext = {}, summaryService = applicationServices.summary, publish = () => {}, - camera = rendererOf(viewer)?.camera, + camera = findCamera(viewer), } = {}, ) { if (!camera) throw new Error('IntelHUD needs a camera port'); diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 6ce376a6b..7932d0152 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -26,7 +26,7 @@ import { import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; import { alprMarkers } from './markerState.js'; import { heightsFor } from '../../queries/heights.js'; -import { cameraOf } from '../../render/rendererOf.js'; +import { cameraOf } from '../../render/cesium/camera.js'; export function createAlprPresentation({ state, services, source }) { const { governorRequestRender } = services.render; diff --git a/src/layers/awareness/focus.js b/src/layers/awareness/focus.js index 00bbe3ba6..448658775 100644 --- a/src/layers/awareness/focus.js +++ b/src/layers/awareness/focus.js @@ -1,6 +1,6 @@ import * as Cesium from 'cesium'; import { VESSEL_FOCUS_RADIUS_M } from './policy.js'; -import { cameraOf } from '../../render/rendererOf.js'; +import { cameraOf } from '../../render/cesium/camera.js'; export function createFocus({ state: layerState, services, parts, source }) { const flightsLayer = services.flights; diff --git a/src/layers/cctv/navigation.js b/src/layers/cctv/navigation.js index 6627c4c4f..49a71bf6d 100644 --- a/src/layers/cctv/navigation.js +++ b/src/layers/cctv/navigation.js @@ -1,6 +1,6 @@ import * as Cesium from 'cesium'; import { CCTV_FOCUS_RESULT } from './policy.js'; -import { cameraOf } from '../../render/rendererOf.js'; +import { cameraOf } from '../../render/cesium/camera.js'; export function createNavigation({ state: layerState, diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 86e24a4e4..6136cb6c0 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -23,7 +23,7 @@ import { translateKnown, } from '../../i18n/index.js'; import { formatQuantity } from '../../ui/unitLabels.js'; -import { cameraOf } from '../../render/rendererOf.js'; +import { cameraOf } from '../../render/cesium/camera.js'; // "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word // joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen). diff --git a/src/layers/installations/controls.js b/src/layers/installations/controls.js index c26d61c81..e4afc0a0c 100644 --- a/src/layers/installations/controls.js +++ b/src/layers/installations/controls.js @@ -6,7 +6,7 @@ import { ANCHOR_REFRESH_M, } from './policy.js'; import { formatNumber, t, tn } from '../../i18n/index.js'; -import { cameraOf } from '../../render/rendererOf.js'; +import { cameraOf } from '../../render/cesium/camera.js'; // installationFeedback.js is shared with the HUD (src/loadingFeedback.js) and // hands back msg()-marked English plus values; getStats translates them here. diff --git a/src/layers/launches/policyHelpers.js b/src/layers/launches/policyHelpers.js index aa1db5716..687c261be 100644 --- a/src/layers/launches/policyHelpers.js +++ b/src/layers/launches/policyHelpers.js @@ -1,6 +1,6 @@ import * as Cesium from 'cesium'; import { t } from '../../i18n/index.js'; -import { cameraOf } from '../../render/rendererOf.js'; +import { cameraOf } from '../../render/cesium/camera.js'; import { SATELLITE_STANDALONE_DEFAULTS, LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M, diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index 86b6a08f0..7b6dbf0ba 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { t } from '../../i18n/index.js'; +import { cameraOf } from '../../render/cesium/camera.js'; import { REPLAY_ASCENT_FALLBACK_SEC, REPLAY_ASCENT_MIN_SEC, @@ -353,7 +354,7 @@ export function createReplay({ state: layerState, services, parts, source }) { parts.overlays.syncMissionOverlayEntries(); parts.policyHelpers.releaseAircraftTracking(layerState._dataManager); layerState._viewer.trackedEntity = undefined; - layerState._viewer.camera.cancelFlight(); + cameraOf(layerState._viewer).cancelFlight(); layerState._animationStarts.set( launchId, Date.now() + @@ -485,20 +486,20 @@ export function createReplay({ state: layerState, services, parts, source }) { } }); - layerState._viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(track.ascentPath[0], 0), + cameraOf(layerState._viewer).flyToBounds( + { center: track.ascentPath[0], radius: 0 }, { - offset: new Cesium.HeadingPitchRange( - initialHeading, - Cesium.Math.toRadians(-20), - REPLAY_INITIAL_RANGE_M, - ), - duration: 1.4, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - complete: () => { + offset: { + heading: initialHeading, + pitch: Cesium.Math.toRadians(-20), + range: REPLAY_INITIAL_RANGE_M, + }, + durationS: 1.4, + easing: 'cubicInOut', + onComplete: () => { cameraReady = true; }, - cancel: () => { + onCancel: () => { if ( token === layerState._replayCameraToken && layerState._replayCameraLaunchId === launchId diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js index 813b7875f..c23752043 100644 --- a/src/layers/launches/selection.js +++ b/src/layers/launches/selection.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { MISSION_GLOBE_VIEW_RANGE_M, MISSION_FOCUS_RANGE_M } from './policy.js'; +import { cameraOf } from '../../render/cesium/camera.js'; export function createSelection({ state: layerState, @@ -32,20 +33,23 @@ export function createSelection({ ); const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4); const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); - viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromRadians( + const camera = cameraOf(viewer); + camera.flyTo( + Cesium.Cartesian3.fromRadians( cartographic.longitude, cartographic.latitude, altitude, ), - orientation: { - heading: viewer.camera.heading, - pitch: -Cesium.Math.PI_OVER_TWO, - roll: 0, + { + orientation: { + heading: camera.pose().heading, + pitch: -Cesium.Math.PI_OVER_TWO, + roll: 0, + }, + durationS: duration, + easing: 'cubicInOut', }, - duration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }); + ); } function entityLaunchId(entity) { @@ -187,11 +191,10 @@ export function createSelection({ Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()), new Cesium.Cartesian3(), ); - layerState._viewer.camera.flyTo({ - destination, + cameraOf(layerState._viewer).flyTo(destination, { orientation: { direction, up }, - duration: 1.1, - complete: () => { + durationS: 1.1, + onComplete: () => { if (layerState._selectedLaunchId === launch.id) startMissionZoomAnchor(launch, range); }, @@ -202,24 +205,20 @@ export function createSelection({ if (!layerState._viewer || !launch) return; parts.replay.stopMissionReplay(); stopMissionZoomAnchor(); - const launchPosition = Cesium.Cartesian3.fromDegrees( - launch.lon, - launch.lat, - ); layerState._viewer.selectedEntity = layerState._dataSource.entities.getById( `rocket-launch:${launch.id}`, ); - layerState._viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(launchPosition, 0), + cameraOf(layerState._viewer).flyToBounds( + { center: { lon: launch.lon, lat: launch.lat }, radius: 0 }, { - offset: new Cesium.HeadingPitchRange( - 0, - Cesium.Math.toRadians(-42), - MISSION_FOCUS_RANGE_M, - ), - duration: 1.4, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - complete: () => { + offset: { + heading: 0, + pitch: Cesium.Math.toRadians(-42), + range: MISSION_FOCUS_RANGE_M, + }, + durationS: 1.4, + easing: 'cubicInOut', + onComplete: () => { if (layerState._selectedLaunchId === launch.id) { startMissionZoomAnchor(launch, MISSION_FOCUS_RANGE_M); } diff --git a/src/layers/radio/navigation.js b/src/layers/radio/navigation.js index f85b7be4d..928ef6c4d 100644 --- a/src/layers/radio/navigation.js +++ b/src/layers/radio/navigation.js @@ -4,7 +4,7 @@ import { GLOBAL_RADIO_ALTITUDE_M, RADIO_GLOBE_RECENTER_MAX_HEIGHT_M, } from './policy.js'; -import { cameraOf } from '../../render/rendererOf.js'; +import { cameraOf } from '../../render/cesium/camera.js'; export function createNavigation({ state: layerState, diff --git a/src/layers/recentImagery/index.js b/src/layers/recentImagery/index.js index cbb6e134b..c420083eb 100644 --- a/src/layers/recentImagery/index.js +++ b/src/layers/recentImagery/index.js @@ -34,7 +34,7 @@ import { } from './model.js'; import { msg, t } from '../../i18n/index.js'; import { READOUT_WORDS, displayDay, displayMetres } from './display.js'; -import { cameraOf } from '../../render/rendererOf.js'; +import { findCamera } from '../../render/rendererOf.js'; /** * Quiet time after the last arrow key before the focused day previews, so a @@ -980,15 +980,17 @@ export function createRecentImageryLayer({ const target = _boxError ? _refusalTarget : null; const camera = _viewer?.camera; const ellipsoid = ellipsoidOf(_viewer); - if (!target || !camera?.flyTo || !ellipsoid?.cartographicToCartesian) - return null; + // No camera port: a viewer of the caller's own whose renderer did not + // bring the camera factory (GEV's own imagery renderer does). + const port = findCamera(_viewer); + if (!target || !port || !ellipsoid?.cartographicToCartesian) return null; const canvas = _viewer.scene?.canvas; const height = fitViewHeightM({ width: canvas?.clientWidth, height: canvas?.clientHeight, - fovy: camera.frustum?.fovy, + fovy: camera?.frustum?.fovy, }); - cameraOf(_viewer).flyTo( + port.flyTo( ellipsoid.cartographicToCartesian({ longitude: toRadians(target.lon), latitude: toRadians(target.lat), diff --git a/src/layers/recentImagery/rendering.js b/src/layers/recentImagery/rendering.js index 316e07345..225e4d9f7 100644 --- a/src/layers/recentImagery/rendering.js +++ b/src/layers/recentImagery/rendering.js @@ -8,6 +8,9 @@ */ import * as Cesium from 'cesium'; import { withTileRequestHook } from '../../render/cesium/tileRequests.js'; +// The layer is Cesium-free and flies through `cameraOf`; its Cesium renderer +// brings the factory a viewer with no renderer bound gets a camera from. +import '../../render/cesium/camera.js'; import { PRODUCTS, gibsTemplate } from './model.js'; const SLOT_IDS = ['a', 'b']; diff --git a/src/layers/streetLevel/cameraFraming.js b/src/layers/streetLevel/cameraFraming.js index fd87ccaa0..67f206413 100644 --- a/src/layers/streetLevel/cameraFraming.js +++ b/src/layers/streetLevel/cameraFraming.js @@ -1,7 +1,7 @@ import * as Cesium from 'cesium'; import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js'; import { heightsFor } from '../../queries/heights.js'; -import { cameraOf } from '../../render/rendererOf.js'; +import { cameraOf } from '../../render/cesium/camera.js'; /** Fly the globe camera once to frame a photo after it opens. */ export function createCameraFraming({ state }) { diff --git a/src/layers/submarineCables/interaction.js b/src/layers/submarineCables/interaction.js index bf8338fd6..3231aedd1 100644 --- a/src/layers/submarineCables/interaction.js +++ b/src/layers/submarineCables/interaction.js @@ -1,6 +1,6 @@ import * as Cesium from 'cesium'; import { cablePicks } from './pickState.js'; -import { cameraOf } from '../../render/rendererOf.js'; +import { cameraOf } from '../../render/cesium/camera.js'; export function createInteraction({ state, inputFor }) { function registerPickEntity(entity, info) { diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index d99eed43e..65b154848 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -10,7 +10,7 @@ import { } from './clock.js'; import { formatUtcDayMonth, t, msg } from '../../i18n/index.js'; import { formatScaleLabel } from '../../ui/unitLabels.js'; -import { cameraOf } from '../../render/rendererOf.js'; +import { cameraOf } from '../../render/cesium/camera.js'; /** * A stored (English) failure in the language. HTTP failures from source.js diff --git a/src/locations.js b/src/locations.js index b954cbe71..501070a65 100644 --- a/src/locations.js +++ b/src/locations.js @@ -13,7 +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'; +import { cameraOf } from './render/cesium/camera.js'; /** Newest landmark arrival; an older post-arrival ground guard yields to it. */ let arrivalGeneration = 0; diff --git a/src/render/cesium/appRenderer.js b/src/render/cesium/appRenderer.js index 8e7341107..88dbc6cec 100644 --- a/src/render/cesium/appRenderer.js +++ b/src/render/cesium/appRenderer.js @@ -121,11 +121,13 @@ export function createCesiumRendererPlugin({ }, destroy() { try { - unbind(); input.dispose(); ports.dispose(); if (!viewer.isDestroyed()) viewer.destroy(); } finally { + // Bound until the viewer is gone: a late `cameraOf` must not + // build a camera of its own over the application's viewer. + unbind(); creditContainer.remove(); } }, diff --git a/src/render/cesium/camera.js b/src/render/cesium/camera.js index 19d67d73a..2264691e9 100644 --- a/src/render/cesium/camera.js +++ b/src/render/cesium/camera.js @@ -6,8 +6,15 @@ */ import * as Cesium from 'cesium'; import { createCameraEvents } from '../camera/events.js'; -import { listenerList, toCartesian } from './common.js'; +import { listenerList, toCartesian, uncaught } from './common.js'; import { cameraFlightActive } from './internals.js'; +import { cameraOf, provideUnboundCamera } from '../rendererOf.js'; + +/** + * `cameraOf`, for code that already reaches Cesium: importing it from here + * loads the factory a viewer with no renderer bound gets its camera from. + */ +export { cameraOf }; /** The contract's easings as Cesium's. */ export const EASINGS = Object.freeze({ @@ -16,6 +23,7 @@ export const EASINGS = Object.freeze({ cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT, cubicOut: Cesium.EasingFunction.CUBIC_OUT, quinticInOut: Cesium.EasingFunction.QUINTIC_IN_OUT, + sineInOut: Cesium.EasingFunction.SINUSOIDAL_IN_OUT, }); /** A contract destination as Cesium's: a Cartesian3, or a Rectangle. */ @@ -265,3 +273,19 @@ export function createCesiumCamera({ scene, camera, report }) { }, }; } + +// A Cesium viewer of a consumer's own, with no renderer bound to it, gets a +// camera port over its camera when code first asks `cameraOf` for one. +provideUnboundCamera((viewer) => + // By shape, not `instanceof`: a consumer's viewer may come from another + // copy of Cesium. + typeof viewer.camera?.flyTo === 'function' && + typeof viewer.camera.moveStart?.addEventListener === 'function' && + typeof viewer.scene?.postUpdate?.addEventListener === 'function' + ? createCesiumCamera({ + scene: viewer.scene, + camera: viewer.camera, + report: uncaught, + }) + : null, +); diff --git a/src/render/cesium/cameraGoldens.js b/src/render/cesium/cameraGoldens.js index f319693f0..a69ffc68f 100644 --- a/src/render/cesium/cameraGoldens.js +++ b/src/render/cesium/cameraGoldens.js @@ -29,6 +29,7 @@ const EASINGS = { cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT, cubicOut: Cesium.EasingFunction.CUBIC_OUT, quinticInOut: Cesium.EasingFunction.QUINTIC_IN_OUT, + sineInOut: Cesium.EasingFunction.SINUSOIDAL_IN_OUT, }; /** One frame of the virtual clock, ms. */ diff --git a/src/render/cesium/cameraPort.test.mjs b/src/render/cesium/cameraPort.test.mjs index 7dc0bd95a..1d96e8155 100644 --- a/src/render/cesium/cameraPort.test.mjs +++ b/src/render/cesium/cameraPort.test.mjs @@ -15,6 +15,7 @@ import { import { FRAME_MS, headlessCamera, withVirtualClock } from './cameraGoldens.js'; import { createCesiumCamera } from './camera.js'; import { createCesiumPorts } from './ports.js'; +import { cameraOf } from '../rendererOf.js'; /** Records the headless scene cannot answer: they need the query port. */ const QUERY_RECORDS = new Set([ @@ -208,3 +209,26 @@ test('a flight Cesium does not start ends cancelled rather than never', async () assert.equal(await flight.done, 'cancelled'); assert.equal(port.flying, false); }); + +test('a Cesium viewer with no renderer bound gets a camera port over its own camera', async () => { + await withVirtualClock(async () => { + const { scene, camera } = headlessCamera(); + scene.postUpdate = new Cesium.Event(); + scene.requestRender = () => {}; + const viewer = { scene, camera }; + const port = cameraOf(viewer); + assert.equal(cameraOf(viewer), port, 'one port per viewer'); + let outcome = null; + port.flyTo( + { lon: 2.35, lat: 48.86, height: 1500 }, + { durationS: 0, onComplete: () => (outcome = 'complete') }, + ); + assert.equal(outcome, 'complete'); + const pose = port.pose(); + assert.ok( + Math.abs(pose.lon - 2.35) < 1e-9 && Math.abs(pose.lat - 48.86) < 1e-9, + ); + assert.ok(Math.abs(pose.height - 1500) < 1e-6); + assert.throws(() => cameraOf({ scene, camera: {} }), /no renderer camera/); + }); +}); diff --git a/src/render/cesium/common.js b/src/render/cesium/common.js index c006d0740..6e8a15eac 100644 --- a/src/render/cesium/common.js +++ b/src/render/cesium/common.js @@ -50,3 +50,18 @@ 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); } + +/** + * 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 + * listener's exception, loud, while the frame and the other listeners carry + * on. + */ +export function uncaught(error) { + if (typeof globalThis.reportError === 'function') + globalThis.reportError(error); + else + queueMicrotask(() => { + throw error; + }); +} diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index f46edf9d2..3325dd9b5 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -14,7 +14,7 @@ import * as Cesium from 'cesium'; import { heightsFor } from '../../queries/heights.js'; import { createCesiumCamera } from './camera.js'; -import { listenerList, toCartesian } from './common.js'; +import { listenerList, toCartesian, uncaught } from './common.js'; import { atEndOfTick, billboardLoadError, @@ -25,21 +25,6 @@ import { 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 - * listener's exception, loud, while the frame and the other listeners carry - * on. - */ -function uncaught(error) { - if (typeof globalThis.reportError === 'function') - globalThis.reportError(error); - else - queueMicrotask(() => { - throw error; - }); -} - /** The contract's colour as Cesium's. */ export function toColor(color, alpha = 1) { if (color === undefined || color === null) diff --git a/src/render/conformance/cameraScripts.js b/src/render/conformance/cameraScripts.js index 58bef2047..837365e20 100644 --- a/src/render/conformance/cameraScripts.js +++ b/src/render/conformance/cameraScripts.js @@ -392,6 +392,29 @@ export const CAMERA_SCRIPTS = Object.freeze([ { record: 'pose' }, ], }, + { + name: 'flyTo sineInOut', + steps: [ + START, + { + do: 'flyTo', + args: [ + { ...AUSTIN, height: 4000 }, + { + durationS: 1, + easing: 'sineInOut', + orientation: { heading: 0.3, pitch: -0.5, roll: 0 }, + }, + ], + }, + { do: 'frames', count: 15 }, + { record: 'pose' }, + { do: 'frames', count: 30 }, + { record: 'pose' }, + { do: 'frames', count: 30 }, + { record: 'pose' }, + ], + }, { name: 'a cancelled flight stops where it was', steps: [ diff --git a/src/render/conformance/goldens/camera.json b/src/render/conformance/goldens/camera.json index 1dc6a53f8..a4261d14e 100644 --- a/src/render/conformance/goldens/camera.json +++ b/src/render/conformance/goldens/camera.json @@ -2144,6 +2144,110 @@ } ] }, + "flyTo sineInOut": { + "records": [ + { + "step": 3, + "kind": "pose", + "value": { + "position": { + "x": -2264202.3786493177, + "y": -16652052.298267456, + "z": 9785772.494992554 + }, + "direction": { + "x": 0.1297004555930398, + "y": 0.9146885584650551, + "z": -0.38278274886948277 + }, + "up": { + "x": 0.08931821646822154, + "y": 0.3736965407891975, + "z": 0.9232405708205872 + }, + "right": { + "x": 0.9875221759666432, + "y": -0.1539341950812336, + "z": -0.0332297390720854 + }, + "heading": 0.03852827617838983, + "pitch": -1.433276538096362, + "roll": 6.283185307179586, + "height": 13074099.901429506, + "frameIsIdentity": true + } + }, + { + "step": 5, + "kind": "pose", + "value": { + "position": { + "x": -1031979.3952742261, + "y": -7589681.480280083, + "z": 4448442.5808664905 + }, + "direction": { + "x": 0.31556045134531424, + "y": 0.8875905643040791, + "z": 0.33556607651118137 + }, + "up": { + "x": 0.10445908107755925, + "y": -0.38397902960887687, + "z": 0.9174139770033249 + }, + "right": { + "x": 0.9431383259772522, + "y": -0.254446644660509, + "z": -0.21388549061082934 + }, + "heading": 0.25036959095382905, + "pitch": -0.6771468656798949, + "roll": 6.283185307179586, + "height": 2484858.7135209497, + "frameIsIdentity": true + } + }, + { + "step": 7, + "kind": "pose", + "value": { + "position": { + "x": -743292.8811925538, + "y": -5466539.584651379, + "z": 3198007.239048282 + }, + "direction": { + "x": 0.36970163659367444, + "y": 0.7940777354746522, + "z": 0.48245336554262613 + }, + "up": { + "x": 0.0693712174939781, + "y": -0.5413817815775576, + "z": 0.8379101388331048 + }, + "right": { + "x": 0.9265572481413991, + "y": -0.2763083722932726, + "z": -0.2552358699659599 + }, + "heading": 0.2999999999999998, + "pitch": -0.5, + "roll": 6.283185307179586, + "height": 3999.9999999988663, + "frameIsIdentity": true + } + } + ], + "flights": [ + { + "step": 1, + "outcome": "complete", + "frames": 62 + } + ] + }, "a cancelled flight stops where it was": { "records": [ { diff --git a/src/render/contract/camera.d.ts b/src/render/contract/camera.d.ts index 482e6927f..98572522e 100644 --- a/src/render/contract/camera.d.ts +++ b/src/render/contract/camera.d.ts @@ -78,7 +78,12 @@ export interface ViewSpec { } export type Easing = - 'linear' | 'quadraticInOut' | 'cubicInOut' | 'cubicOut' | 'quinticInOut'; + | 'linear' + | 'quadraticInOut' + | 'cubicInOut' + | 'cubicOut' + | 'quinticInOut' + | 'sineInOut'; /** * A flight's omitted orientation is the same as `setView`'s. Its omitted diff --git a/src/render/rendererOf.js b/src/render/rendererOf.js index 0103b66c2..58df8c54a 100644 --- a/src/render/rendererOf.js +++ b/src/render/rendererOf.js @@ -9,13 +9,36 @@ const renderers = new WeakMap(); /** - * Bind a renderer to its scene object. + * Camera ports built for scene objects no renderer is bound to (a viewer of + * a consumer's own, say), each `{port, dispose}` and kept for as long as its + * scene object lives, or until a renderer is bound to it. + */ +const unboundCameras = new WeakMap(); +let buildUnboundCamera = null; + +/** + * Provide the factory `cameraOf` builds a camera port with for a scene + * object no renderer is bound to. The renderer adapter provides it as it + * loads; a later call replaces it. + * @param {(host: object) => ({port: object, dispose: () => void} | null)} build + */ +export function provideUnboundCamera(build) { + buildUnboundCamera = build; +} + +/** + * Bind a renderer to its scene object. A camera `cameraOf` built for it + * while it had no renderer is disposed: its listeners hear nothing more, + * and `cameraOf` answers with the renderer's camera from then on. * @param {object} host The scene object callers hold. * @param {object} renderer * @returns {() => void} Unbinds, if the binding is still this one. */ export function bindRenderer(host, renderer) { if (renderers.has(host)) throw new Error('This scene already has a renderer'); + // The renderer's camera supersedes one built while there was none. + unboundCameras.get(host)?.dispose(); + unboundCameras.delete(host); renderers.set(host, renderer); return () => { if (renderers.get(host) === renderer) renderers.delete(host); @@ -29,13 +52,43 @@ export function bindRenderer(host, renderer) { export const rendererOf = (host) => (host && renderers.get(host)) || null; /** - * The camera port of the renderer bound to `host`. + * The camera port of the renderer bound to `host`; with no renderer bound, + * one built over `host` by the provided factory and kept with it while + * `host` lives. + * @param {object|null|undefined} host + * @returns {import('./contract/camera').CameraPort | null} Null when `host` + * has no renderer and no camera can be built for it, or is destroyed. + */ +export function findCamera(host) { + const camera = rendererOf(host)?.camera; + if (camera) return camera; + if (!host || typeof host !== 'object') return null; + if (host.isDestroyed?.()) { + // Gone with its scene: let the built camera go with it. + unboundCameras.get(host)?.dispose(); + unboundCameras.delete(host); + return null; + } + let built = unboundCameras.get(host); + if (!built && buildUnboundCamera) { + built = buildUnboundCamera(host); + if (built) unboundCameras.set(host, built); + } + return built?.port ?? null; +} + +/** + * `findCamera`, for code that needs the camera. * @param {object} host * @returns {import('./contract/camera').CameraPort} - * @throws When no renderer is bound to `host`. + * @throws When there is no camera for `host`. */ export function cameraOf(host) { - const camera = rendererOf(host)?.camera; - if (!camera) throw new Error('This scene has no renderer camera'); - return camera; + const camera = findCamera(host); + if (camera) return camera; + throw new Error( + host?.isDestroyed?.() + ? 'This scene has been destroyed' + : 'This scene has no renderer camera', + ); } diff --git a/src/render/rendererOf.test.mjs b/src/render/rendererOf.test.mjs index 9f40e9dd9..90b08793c 100644 --- a/src/render/rendererOf.test.mjs +++ b/src/render/rendererOf.test.mjs @@ -1,6 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { bindRenderer, cameraOf, rendererOf } from './rendererOf.js'; +import { + bindRenderer, + cameraOf, + findCamera, + provideUnboundCamera, + rendererOf, +} from './rendererOf.js'; test('a scene finds its renderer until it is unbound', () => { const scene = {}; @@ -32,3 +38,52 @@ test('cameraOf names the missing renderer instead of returning nothing', () => { assert.equal(cameraOf(scene), camera); unbind(); }); + +test('with no renderer bound, cameraOf builds one camera per scene until a renderer is bound', (t) => { + t.after(() => provideUnboundCamera(null)); + const built = []; + provideUnboundCamera((host) => { + if (!host.camera) return null; + const entry = { port: { id: built.length }, disposed: 0 }; + entry.dispose = () => (entry.disposed += 1); + built.push(entry); + return entry; + }); + const viewer = { camera: {} }; + assert.equal(cameraOf(viewer), built[0].port); + assert.equal(cameraOf(viewer), built[0].port, 'kept, not rebuilt'); + assert.equal(built.length, 1); + assert.throws( + () => cameraOf({}), + /no renderer camera/, + 'nothing to build over', + ); + assert.equal(findCamera({}), null); + assert.equal(findCamera(null), null); + + const camera = { pose() {} }; + const unbind = bindRenderer(viewer, { camera }); + assert.equal( + built[0].disposed, + 1, + 'the renderer supersedes the built camera', + ); + assert.equal(cameraOf(viewer), camera); + unbind(); + assert.equal(cameraOf(viewer), built[1].port, 'unbound again: a fresh one'); +}); + +test('a destroyed scene gets no built camera, and lets go of the one it had', (t) => { + t.after(() => provideUnboundCamera(null)); + let disposed = 0; + provideUnboundCamera(() => ({ port: {}, dispose: () => (disposed += 1) })); + let destroyed = false; + const viewer = { isDestroyed: () => destroyed }; + cameraOf(viewer); + destroyed = true; + assert.throws(() => cameraOf(viewer), /destroyed/); + assert.equal(disposed, 1); + assert.equal(findCamera(viewer), null, 'findCamera answers null instead'); + assert.throws(() => cameraOf(viewer), /destroyed/); + assert.equal(disposed, 1, 'once'); +}); diff --git a/src/render/stub/camera.js b/src/render/stub/camera.js index cef9fda92..865f66d94 100644 --- a/src/render/stub/camera.js +++ b/src/render/stub/camera.js @@ -61,6 +61,7 @@ const EASINGS = { cubicInOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2), quinticInOut: (t) => (t < 0.5 ? 16 * t ** 5 : 1 - (-2 * t + 2) ** 5 / 2), cubicOut: (t) => (t - 1) ** 3 + 1, + sineInOut: (t) => 0.5 * (1 - Math.cos(Math.PI * t)), }; /** Above this height, a descending flight eases out rather than in and out. */ diff --git a/src/render/unboundCamera.test.mjs b/src/render/unboundCamera.test.mjs new file mode 100644 index 000000000..65a857b5b --- /dev/null +++ b/src/render/unboundCamera.test.mjs @@ -0,0 +1,146 @@ +/** + * A public entry that reaches the renderer bridge (`rendererOf.js`) may hand + * `cameraOf` a viewer of the consumer's own, with no renderer bound. The + * bridge builds a camera port for it with the factory the Cesium adapter + * provides as `render/cesium/camera.js` loads, so every such entry must load + * that module too. + * + * The graph is every static import, re-export and literal dynamic import of + * the entry's module and those it reaches, read by the parsers the Cesium + * ratchet uses; bare specifiers (packages) end the walk. + */ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { parsers } from 'prettier/plugins/babel.mjs'; +import { parse as parseSfc } from 'vue/compiler-sfc'; + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); +const BRIDGE = 'src/render/rendererOf.js'; +const FACTORY = 'src/render/cesium/camera.js'; + +/** + * Entries that take their Cesium code injected: the module named here is + * GEV's Cesium side for them, and must load the factory. Given another, the + * entry's flight finds no camera (`findCamera`) and does nothing. + */ +const INJECTED = { + './layers/recent-imagery': 'src/layers/recentImagery/rendering.js', +}; + +const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')); + +/** The browser module an export resolves to, or null for Node-only ones. */ +function browserTarget(declaration) { + if (typeof declaration === 'string') return declaration; + if (!declaration || typeof declaration !== 'object') return null; + for (const condition of ['browser', 'import', 'default']) { + const target = browserTarget(declaration[condition]); + if (target) return target; + } + return null; +} + +function specifiersOf(file) { + let code = readFileSync(path.join(root, file), 'utf8'); + if (file.endsWith('.vue')) { + const { descriptor } = parseSfc(code, { filename: file }); + code = [descriptor.script, descriptor.scriptSetup] + .filter(Boolean) + .map((block) => block.content) + .join('\n'); + } + const found = []; + const walk = (node) => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) return node.forEach(walk); + if ( + (node.type === 'ImportDeclaration' || + node.type === 'ExportNamedDeclaration' || + node.type === 'ExportAllDeclaration') && + node.source + ) + found.push({ specifier: node.source.value, lazy: false }); + if ( + node.type === 'ImportExpression' && + node.source?.type === 'StringLiteral' + ) + found.push({ specifier: node.source.value, lazy: true }); + if ( + node.type === 'CallExpression' && + node.callee?.type === 'Import' && + node.arguments[0]?.type === 'StringLiteral' + ) + found.push({ specifier: node.arguments[0].value, lazy: true }); + for (const key of Object.keys(node)) + if (key !== 'loc' && key !== 'start' && key !== 'end') walk(node[key]); + }; + walk(parsers.babel.parse(code)); + return found; +} + +/** A relative specifier as a repository file, or null when it is a package. */ +function resolve(from, specifier) { + const bare = specifier.split('?')[0]; + if (!bare.startsWith('.')) return null; + const base = path.join(path.dirname(from), bare); + const candidates = ['', '.js', '.mjs', '.vue', '/index.js', '/index.mjs']; + for (const suffix of candidates) { + const full = path.join(root, base + suffix); + if (existsSync(full) && statSync(full).isFile()) + return path.normalize(base + suffix); + } + // A subgraph dropped here would hide a missing factory. + throw new Error(`${from}: cannot resolve ${specifier}`); +} + +/** + * The modules `entry` reaches. With `eager`, only through static imports: + * what has loaded once the entry has, before any lazy import resolves. + */ +function reachable(entry, { eager = false } = {}) { + const seen = new Set([entry]); + const queue = [entry]; + while (queue.length) { + const file = queue.pop(); + if (!/\.(m?js|vue)$/.test(file)) continue; + for (const { specifier, lazy } of specifiersOf(file)) { + if (eager && lazy) continue; + const next = resolve(file, specifier); + if (next && !seen.has(next)) { + seen.add(next); + queue.push(next); + } + } + } + return seen; +} + +test('every public entry that reaches the renderer bridge loads the unbound camera factory', () => { + const missing = []; + let bridged = 0; + for (const [key, declaration] of Object.entries(pkg.exports)) { + const target = browserTarget(declaration); + if (typeof target !== 'string' || !target.startsWith('./src/')) continue; + const entry = path.normalize(target.slice(2)); + const graph = reachable(entry); + // Any path to the bridge counts, lazy or not; only an eager one loads + // the factory in time for the first `cameraOf`. + if (!graph.has(BRIDGE)) continue; + bridged += 1; + const carrier = INJECTED[key]; + const loads = (file) => reachable(file, { eager: true }).has(FACTORY); + if (!loads(entry) && !(carrier && loads(carrier))) missing.push(key); + } + assert.ok(bridged > 0, 'no entry reaches the bridge: the walk is broken'); + assert.deepEqual( + missing, + [], + `These entries can reach cameraOf without ${FACTORY}; import it where the entry first reaches the bridge.`, + ); +}); diff --git a/src/sharelink.js b/src/sharelink.js index 69851eaca..c01647aeb 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -1,5 +1,5 @@ import { toDegrees, toRadians } from './geo/index.js'; -import { rendererOf } from './render/rendererOf.js'; +import { findCamera } from './render/rendererOf.js'; import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from './bloom.js'; import { migrateDetectionState, @@ -69,7 +69,7 @@ export class ShareLinkManager { isNavigationCurrent, cancelOwnedNavigation, initialShareCode = null, - camera = rendererOf(viewer)?.camera, + camera = findCamera(viewer), } = {}, ) { if (!camera) throw new Error('ShareLinkManager needs a camera port'); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 30af012e4..4383c6618 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -30,7 +30,7 @@ import { } from './cyberSonarControls.js'; import * as Cesium from 'cesium'; -import { cameraOf } from '../render/rendererOf.js'; +import { cameraOf } from '../render/cesium/camera.js'; import { aircraftTrackingTarget } from '../cockpitTracking.js'; diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index c33ba3c15..b7ca12a90 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -1,5 +1,6 @@ /** Camera placement, ground acquisition and motion correction for the Cockpit controller. */ import * as Cesium from 'cesium'; +import { cameraOf } from '../render/cesium/camera.js'; import { cockpitAnchorCorrectionStep, cockpitGroundSafeHeight, @@ -349,7 +350,7 @@ export function update() { } } - this.viewer.camera.setView({ + cameraOf(this.viewer).setView({ destination: this.scratchCamera, orientation: { direction: this.scratchForward, up: this.scratchUp }, }); diff --git a/src/ui/cockpitPresence.test.mjs b/src/ui/cockpitPresence.test.mjs index f9ddcd24b..7b7e2dafe 100644 --- a/src/ui/cockpitPresence.test.mjs +++ b/src/ui/cockpitPresence.test.mjs @@ -10,6 +10,7 @@ import { CockpitViewController } from './cockpitController.js'; import { useCockpitStore } from './stores/cockpit.js'; import CockpitView from './views/CockpitView.vue'; import ContextView from './views/ContextView.vue'; +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; const INFO = Object.freeze({ icao24: 'abc123', @@ -51,6 +52,7 @@ function fixture(t) { }, trackedEntityChanged: listeners(), }; + bindFakeCesiumCamera(viewer); const cockpit = new CockpitViewController(viewer, { services: { flightsLayer: { getTrackedInfo: () => INFO, refreshTr3b() {} }, diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index fd375a698..24d89c3e8 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -1,5 +1,6 @@ /** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */ import { t } from '../i18n/index.js'; +import { cameraOf } from '../render/rendererOf.js'; import { formatHeadingReadout, normalizeHeading, @@ -150,7 +151,7 @@ export function enter() { : null; // Retire deferred navigation before cancelFlight can run its callbacks. this.onCameraTakeover?.(); - this.viewer.camera.cancelFlight(); + cameraOf(this.viewer).cancelFlight(); this.trackedEntity = entity; this.trackedEntityWasShown = entity.show; entity.show = false; diff --git a/src/worldFocus.js b/src/worldFocus.js index c6c2dbe09..4cee39478 100644 --- a/src/worldFocus.js +++ b/src/worldFocus.js @@ -2,7 +2,7 @@ * One-click camera transfer for layer-owned world targets. */ import { WGS84, toRadians } from './geo/index.js'; -import { rendererOf } from './render/rendererOf.js'; +import { findCamera } from './render/rendererOf.js'; export const WORLD_FOCUS_REQUEST_EVENT = 'gev:world-request-focus'; export const WORLD_CLICK_FOCUS_DURATION_SEC = 1.9; @@ -69,7 +69,7 @@ export function routeWorldFocusRequest(event, runExplicitFocus, fly) { /** Fly to a world target after ownership has been released. */ export function flyToWorldTarget(viewer, target = {}) { - const camera = rendererOf(viewer)?.camera; + const camera = findCamera(viewer); const framing = WORLD_FOCUS_FRAMING[target.kind]; if (!camera || !framing || !isValidWorldFocusTarget(target)) return false; const poseHeading = camera.pose().heading;