diff --git a/src/app/embed.js b/src/app/embed.js index afcc17fbb..628dc24af 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -144,10 +144,10 @@ const delay = (ms, signal) => }); /** Fly the camera to a view's camera and resolve when the flight ends. */ -function flyTo(viewer, camera) { +function flyTo(engine, camera) { announceNavigationAuthority('embed-view'); return new Promise((resolve) => { - viewer.camera.flyTo({ + engine.camera.flyTo({ destination: geo.cartesian3.fromDegrees( camera.lon, camera.lat, @@ -173,7 +173,7 @@ function flyTo(viewer, camera) { */ export async function applyView( view, - { viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS }, + { engine, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS }, ) { const steps = []; const act = async (name, args) => { @@ -208,7 +208,7 @@ export async function applyView( if (view.annotations.length) await act('annotate_map', { annotations: [...view.annotations] }); if (!view.follow) { - steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); + steps.push({ step: 'camera', ok: await flyTo(engine, view.camera) }); return steps; } // A followed entity owns the camera, and a camera flight would end the @@ -227,7 +227,7 @@ export async function applyView( }; let followed = await follow(); if (!followed) { - steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); + steps.push({ step: 'camera', ok: await flyTo(engine, view.camera) }); for (let attempt = 1; attempt < FOLLOW_ATTEMPTS && !followed; attempt++) { if (signal?.aborted) break; await delay(retryMs, signal); @@ -242,11 +242,14 @@ export async function applyView( /** * Install embed mode when the page asks for it, and draw any annotations the - * opening link carries. Returns a function that removes the message handler. + * opening link carries. `keepRendering({ windowRef })`, when the engine needs + * it, keeps an inline panel drawing (keepPanelRendering). Returns a function + * that removes the message handler. */ export function installViews({ shell, - viewer, + engine, + keepRendering = null, dataManager, run, signal, @@ -265,9 +268,10 @@ export function installViews({ windowRef.document.body.classList.add('ui-embed'); shell.setCleanView?.(true); - const stopRendering = isEmbeddedInline() - ? keepPanelRendering(viewer, { windowRef }) - : () => {}; + const stopRendering = + isEmbeddedInline() && keepRendering + ? keepRendering({ windowRef }) + : () => {}; // A framing page talks to the app across frames; an inline panel shares // the page with it and talks through the page's own window. const peer = isEmbeddedInline() ? windowRef : windowRef.parent; @@ -304,7 +308,7 @@ export function installViews({ } queue = queue.then(async () => { if (signal?.aborted) return; - const steps = await applyView(view, { viewer, dataManager, run, signal }); + const steps = await applyView(view, { engine, dataManager, run, signal }); post( { type: EMBED_APPLIED_MESSAGE, diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index 5fa949342..633549c96 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -10,7 +10,7 @@ import { import { LayerLifecycle } from '../data/lifecycle.js'; import { createView } from '../view/index.js'; -const fakeViewer = () => { +const fakeEngine = () => { const flights = []; return { flights, @@ -48,7 +48,7 @@ test('embed mode is opt-in by query parameter', () => { }); test('a view is applied through the app actions in order', async () => { - const viewer = fakeViewer(); + const engine = fakeEngine(); const { calls, run } = recorder(); const view = createView({ camera: { lat: 25, lon: 121, altitude_m: 300000, pitch_deg: -40 }, @@ -58,7 +58,7 @@ test('a view is applied through the app actions in order', async () => { annotations: [{ type: 'pin', target: 'Taipei 101' }], }); const steps = await applyView(view, { - viewer, + engine, dataManager: fakeLayers(new Set(['earthquakes'])), run, }); @@ -78,7 +78,7 @@ test('a view is applied through the app actions in order', async () => { ['annotate_map', null, null], ], ); - assert.equal(viewer.flights.length, 1); + assert.equal(engine.flights.length, 1); assert.ok(steps.every((step) => step.ok)); assert.ok(steps.some((step) => step.step === 'camera')); }); @@ -93,7 +93,7 @@ test('a followed entity is retried until its layer has it', async () => { follow: { kind: 'aircraft', id: 'abc123' }, }); const steps = await applyView(view, { - viewer: fakeViewer(), + engine: fakeEngine(), dataManager: fakeLayers(new Set()), run, retryMs: 1, @@ -125,7 +125,7 @@ test('an embedded page takes views only from its parent and answers it', async ( const { calls, run } = recorder(); const remove = installViews({ shell, - viewer: fakeViewer(), + engine: fakeEngine(), dataManager: fakeLayers(new Set()), run, location: { search: '?embed=1', hash: '' }, @@ -204,7 +204,7 @@ test('an app loaded inline into a panel page talks through its own window', asyn assert.equal(isEmbedded({ search: '' }), true); const remove = installViews({ shell: { initialRestorePromise: Promise.resolve() }, - viewer: fakeViewer(), + engine: fakeEngine(), dataManager: fakeLayers(new Set()), run: recorder().run, location: { search: '', hash: '' }, @@ -288,7 +288,7 @@ test('annotations in a link are drawn after it restores, embedded or not', async ); installViews({ shell: { initialRestorePromise: Promise.resolve() }, - viewer: fakeViewer(), + engine: fakeEngine(), dataManager: fakeLayers(new Set()), run, location: { search: '', hash: `#v=2&lat=1&lon=2&an=${an}` }, @@ -368,17 +368,17 @@ test('a panel reports a render error in full and stops drawing', () => { }); test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => { - const viewer = fakeViewer(); + const engine = fakeEngine(); const { calls, run } = recorder(); const steps = await applyView( createView({ camera: { lat: 32.7, lon: -117.2 }, follow: { kind: 'military_aircraft', id: 'AE1234', cockpit: true }, }), - { viewer, dataManager: fakeLayers(new Set()), run, retryMs: 0 }, + { engine, dataManager: fakeLayers(new Set()), run, retryMs: 0 }, ); // Following worked at once, so no camera flight ended it. - assert.equal(viewer.flights.length, 0); + assert.equal(engine.flights.length, 0); assert.deepEqual(calls.slice(-2), [ ['track_entity', { query: 'ae1234', layerId: 'military' }], ['control_cockpit', { action: 'enter', targetLayer: 'military' }], diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 19e86da86..cdff8062d 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -5,7 +5,7 @@ import { t } from '../i18n/index.js'; // loads the Navara globe the same way, so the Cesium-mode bundle carries // none of it. -import { DATA_CREDITS, registerDataCredits } from '../data/dataCredits.js'; +import { registerDataCredits } from '../data/dataCredits.js'; import { createLayerAvailability } from './layers/renderers.js'; import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js'; @@ -29,35 +29,6 @@ export function navaraModeNotice(availability) { }); } -/** Static attribution for the shell and the layers available on Navara. */ -const NAVARA_CREDIT_KEYS = new Set([ - 'inaturalist', - 'photon-geocoder', - 'openstreetmap', - 'reearth-terrain', - 'opensky', - 'adsblol', - 'adsbdb', - 'aisstream', - 'celestrak', - 'launch-library-2', - 'usgs', - 'eccc-aqhi', - 'wfigs', - 's2-ghostmaps', - 'telegeography', - 'wind-gfs', - 'ecmwf-ifs', - 'weather-noaa', - 'weather-lightning', - 'weather-cyclones', - 'dams', - 'firms', - 'gbfs', - 'overture-military-names', - 'radio-browser', -]); - /** * The scene on the experimental Navara engine: no viewer and no Cesium * objects. Navara builds the map sources from the controller's descriptions @@ -109,10 +80,7 @@ export async function createNavaraScene({ signal.throwIfAborted(); // After the map's own credits, so Google's copyright lines come first in // Navara's attribution card. - registerDataCredits( - engine, - (credits ?? DATA_CREDITS).filter(({ key }) => NAVARA_CREDIT_KEYS.has(key)), - ); + registerDataCredits(engine, credits); return { viewer: null, engine, diff --git a/src/app/navaraScene.test.mjs b/src/app/navaraScene.test.mjs index 023ce929f..18a57fc14 100644 --- a/src/app/navaraScene.test.mjs +++ b/src/app/navaraScene.test.mjs @@ -174,30 +174,11 @@ test('the Navara scene tears down the controller, then the globe', async (t) => assert.equal(view.__GOOGLE_MAPS_API_KEY__, undefined); }); -test('Navara keeps attribution for every available W1/W2 source and excludes unavailable data', async (t) => { - const keys = [ - 'opensky', - 'adsblol', - 'adsbdb', - 'aisstream', - 'celestrak', - 'launch-library-2', - 'usgs', - 'eccc-aqhi', - 'wfigs', - 'firms', - 'gbfs', - 'overture-military-names', - 'radio-browser', - 'photon-geocoder', - 'openstreetmap', - 'reearth-terrain', - ]; +test('Navara registers the attribution of every data source, as Cesium does', async (t) => { + // Every layer draws on Navara, so none of their credits may be dropped. + const keys = ['opensky', 'nasa-gibs', 'nyc-dot-cctv', 'mapillary', 'gdelt']; const run = await startScene({ - credits: [...keys, 'nasa-gibs'].map((key) => ({ - key, - html: `Credit ${key}`, - })), + credits: keys.map((key) => ({ key, html: `Credit ${key}` })), }); t.after(run.teardown); assert.deepEqual( diff --git a/src/app/tools.js b/src/app/tools.js index c3f437c63..bba25c22e 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -13,7 +13,7 @@ import { currentSceneOf, detachPartsOnTeardown, } from './sceneParts.js'; -import { installViews, isEmbeddedInline } from './embed.js'; +import { installViews, isEmbeddedInline, keepPanelRendering } from './embed.js'; import { installScopeMask, destroyScopeMask, @@ -310,20 +310,8 @@ function cesiumToolBackend({ engine, tileset }) { viewer.useDefaultRenderLoop = !hidden; cockpitCloudEffects?.setSuspended?.(hidden); }, - attachViews( - { styleManager, dataManager, voiceCommands }, - { signal, defer }, - ) { - defer( - installViews({ - shell: styleManager, - viewer, - dataManager, - run: (name, args) => voiceCommands.runner(name, args, { signal }), - signal, - }), - ); - }, + // An inline panel the host reports hidden stops Cesium's render loop. + keepRendering: (options) => keepPanelRendering(viewer, options), }; } @@ -336,7 +324,7 @@ function cesiumToolBackend({ engine, tileset }) { * this engine), `recentImagery(layer)` the panel's globe, * `attach(context, attachment)` adds * the engine's own tools, `suspend(hidden, controls)` its hidden-tab - * handling and `attachViews(context, attachment)` its embedded views. The + * handling and `keepRendering` what keeps an inline panel drawing. The * shared tools keep their session state across engines. Returns the * annotation board (null without one) and voice commands. */ @@ -460,9 +448,16 @@ async function attachEngineTools( signal, defer, }); - backend.attachViews?.( - { styleManager, dataManager, voiceCommands }, - { signal, defer }, + // Annotation links and embed mode, on every engine. + defer( + installViews({ + shell: styleManager, + engine, + keepRendering: backend.keepRendering ?? null, + dataManager, + run: (name, args) => voiceCommands.runner(name, args, { signal }), + signal, + }), ); return { annotations, voiceCommands, startupChrome: backend.startupChrome }; } diff --git a/src/app/viewerlessTools.test.mjs b/src/app/viewerlessTools.test.mjs index 88dfd5dee..15ba0a5a9 100644 --- a/src/app/viewerlessTools.test.mjs +++ b/src/app/viewerlessTools.test.mjs @@ -17,9 +17,17 @@ test('without a viewer the tools run the governor on the engine, show the notice window: globalThis.window, document: globalThis.document, localStorage: globalThis.localStorage, + location: globalThis.location, }; const listeners = new Map(); globalThis.window = {}; + // A link that carries an annotation, drawn once the view restores. + const linked = encodeURIComponent( + JSON.stringify([{ type: 'pin', target: 'Austin' }]), + ); + globalThis.location = { search: '', hash: `#v=2&lat=1&lon=2&an=${linked}` }; + let restored; + const initialRestorePromise = new Promise((resolve) => (restored = resolve)); globalThis.localStorage = { getItem: () => null, setItem() {}, @@ -54,11 +62,14 @@ test('without a viewer the tools run the governor on the engine, show the notice // The voice pointer listens on the engine's canvas. const canvas = new EventTarget(); const engine = createFakeEngine({ kind: 'navara', canvas }); + // Navara's extension without its view: no annotation board. + engine.extensions = { navara: { alignDaylightToRestoredView() {} } }; const notices = []; const chrome = []; const cleanups = []; let flushed = 0; const styleManager = { + initialRestorePromise, _showGlobalStatusNotice: (label, options) => notices.push({ label, options }), }; @@ -102,6 +113,18 @@ test('without a viewer the tools run the governor on the engine, show the notice defer: (dispose) => cleanups.push(dispose), }); assert.ok(tools.sceneDirector); + const ran = []; + tools.voiceCommands.runner = async (name, args) => { + ran.push([name, args]); + return { ok: true }; + }; + restored(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual( + ran, + [['annotate_map', { annotations: [{ type: 'pin', target: 'Austin' }] }]], + "the link's annotations are drawn on this engine too", + ); assert.equal(tools.annotations, null); assert.ok(tools.voiceCommands, 'voice runs on the engine'); assert.equal(window.__gevVoiceCommands, tools.voiceCommands); diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index f039fdb29..831be442c 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -137,8 +137,8 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () => // // The transaction has exactly two camera-owner mutations, and each one // stamps: - // 1. selectedLayer.trackById(id) -> viewer.trackedEntity - // -> viewer.trackedEntityChanged -> _stampNavigation() + // 1. selectedLayer.trackById(id) -> engine.camera.follow + // -> engine.camera.followChanged -> _stampNavigation() // 2. cockpitView.enter() -> onCameraTakeover() -> _stampNavigation() const transaction = body( cockpitTracking, @@ -162,7 +162,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () => // Seam 1: a new tracker handoff stamps; restoring the session does not. assert.match( ui, - /viewer\.trackedEntityChanged\.addEventListener\(\s*\(entity\) => \{[\s\S]*?if \(entity && !this\._disposed && !entity\.gevRestoring\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/, + /camera\.followChanged\.addEventListener\(\(handle\) => \{[\s\S]*?if \(handle && !this\._disposed && !handle\.native\?\.gevRestoring\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/, 'tracker handoff must stamp', ); // Seam 2 is pinned by "Cockpit takeover invalidates deferred work" above. @@ -255,7 +255,7 @@ test('accepted navigation releases through PR15-aware ownership before flight', 'origin: trackingOrigin', 'satellitesLayer.stopTracking?.({ origin: trackingOrigin })', 'rocketLaunchesLayer.releaseCameraOwnership?.()', - 'if (this.viewer) this.viewer.trackedEntity = undefined;', + 'this.engine?.camera.following()?.stop();', "interruptCameraMotion('explicit-navigation')", 'camera?.cancelFlight();', 'camera?.release();', diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index d555c9d3b..9a8847386 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -259,12 +259,15 @@ export function createNavaraEngine( }) ?? null; const { surface } = createNavaraSurface(view, hooks); // Navara's controller zooms through the ground; keep the camera above it. - // With the camera inputs off nothing zooms, and the app places the camera - // itself (the cockpit eye, 12 m up): the floor's 30 m would fight it. + // With the camera inputs off or suspended nothing zooms, and the app places + // the camera itself (the cockpit eye, 12 m up): the floor's 30 m would + // fight it. A follow suspends them only to take drag and wheel itself. groundFloor = createGroundFloor({ surface, position: () => model.camera.livePosition, - isFree: () => model.isFree() && input.cameraInputs(), + isFree: () => + model.isFree() && + (input.input.cameraInputsEnabled() || Boolean(follow.following())), lift: (position) => model.lift(position), setMinimumZoomDistance: (distance) => input.input.setCameraOptions({ minimumZoomDistance: distance }), diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index 6e3b64702..3ba9ea9b8 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -577,6 +577,21 @@ test('the floor stands aside while the camera inputs are off (the cockpit drives engine.destroy(); }); +test('the floor stands aside while the camera inputs are suspended (the cockpit holds them)', () => { + const { engine, view } = setup(); + view.sampleTerrainHeight = () => 100; + const resume = engine.input.suspendCameraInputs(); + engine.camera.setView({ + destination: cartesian3.fromDegrees(2.35, 48.85, 112), + }); + view.emit('preRender', 1); + near(heightOf(engine), 112, 1e-6, 'the cockpit eye 12 m up is kept'); + resume(); + view.emit('preRender', 2); + near(heightOf(engine), 130, 1e-6, 'lifted once the inputs are back'); + engine.destroy(); +}); + test('the ground floor reports its own ground and margin (diagnostics, not contract)', () => { const { engine } = setup(); assert.equal(typeof engine.groundFloor.ground, 'function'); diff --git a/src/layers/aircraft/sceneBinding.test.mjs b/src/layers/aircraft/sceneBinding.test.mjs index 91eb2eb06..9fcca7dd3 100644 --- a/src/layers/aircraft/sceneBinding.test.mjs +++ b/src/layers/aircraft/sceneBinding.test.mjs @@ -11,13 +11,13 @@ function observedShell(t) { t.after(() => { globalThis.window = previous; }); - const trackedEntityChanged = createEngineEvent(); + const followChanged = createEngineEvent(); const shell = { generation: 1, - trackedEntityChanged, + followChanged, }; const bindings = new LayerBindings({ - viewer: { trackedEntityChanged }, + engine: { camera: { followChanged } }, services: {}, operations: { _stampNavigation() { @@ -32,10 +32,10 @@ function observedShell(t) { // What the tracking modules do around follow.start on a resume or a share // restore (the shared aircraft tracking.js and satellites tracking.js). -function follow(trackedEntityChanged, entity, restoring) { +function follow(followChanged, entity, restoring) { if (restoring) entity.gevRestoring = true; try { - trackedEntityChanged.raiseEvent(entity); + followChanged.raiseEvent({ native: entity }); } finally { if (restoring) delete entity.gevRestoring; } @@ -86,7 +86,7 @@ test('resuming an aircraft on Cesium preserves the pending share navigation', (t (id, options) => { restored = { id, ...options }; follow( - shell.trackedEntityChanged, + shell.followChanged, { gevSelectionOrigin: options.origin }, options.resume, ); @@ -100,7 +100,7 @@ test('resuming an aircraft on Cesium preserves the pending share navigation', (t 'restored tracking cannot supersede and clear itself', ); for (const origin of ['user', 'voice', 'programmatic']) { - follow(shell.trackedEntityChanged, { gevSelectionOrigin: origin }, false); + follow(shell.followChanged, { gevSelectionOrigin: origin }, false); } assert.equal( shell.generation, @@ -112,12 +112,12 @@ test('resuming an aircraft on Cesium preserves the pending share navigation', (t test('a restored aircraft followed again later is new navigation', (t) => { const shell = observedShell(t); const entity = { gevSelectionOrigin: 'share-restore' }; - follow(shell.trackedEntityChanged, entity, true); + follow(shell.followChanged, entity, true); assert.equal(shell.generation, 1); assert.equal('gevRestoring' in entity, false, 'the mark does not stick'); // Cockpit exit or a refocus hands the same aircraft back to the camera. - shell.trackedEntityChanged.raiseEvent(undefined); - shell.trackedEntityChanged.raiseEvent(entity); + shell.followChanged.raiseEvent(null); + shell.followChanged.raiseEvent({ native: entity }); assert.equal(shell.generation, 2); }); diff --git a/src/scenes/director.js b/src/scenes/director.js index b89a23d2f..81664493e 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -119,7 +119,7 @@ export class SceneDirector { available: () => !this._destroyed && !this._running && - !this._scene?.viewer?.trackedEntity, + !this._scene?.engine?.camera.following(), execute: (action, signal) => this._trackWork(this._executeInteraction(action, signal)), }); diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 34687c4da..f11347f93 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -2121,8 +2121,7 @@ for (const createHarness of GROUND_HARNESSES) { const follow = engine.camera.follow({ position: () => ({ x: 6378137, y: 0, z: 1000 }), viewFrom: { x: 0, y: -4800, z: 3300 } }); store.controls.dispatch('card', store.panel.owner); await settle(); - if (harness.cesium) assert.equal(store.panel.card, null, 'tracked subjects keep camera ownership'); - else assert.equal(store.panel.card.text, 'Details', 'the engine without a legacy tracked subject keeps its interaction policy'); + assert.equal(store.panel.card, null, 'a followed subject keeps camera ownership'); follow.stop(); store.controls.dispatch('card', store.panel.owner); await settle(); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index bd5953470..801f30550 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -314,7 +314,7 @@ export class StyleManager extends ShellScene { this._initSceneOverlays(); this._displayBindings = new DisplayBindings({ - viewer, + engine, services: { cycleDetectionMode: services.cycleDetectionMode, isScopeMaskEnabled: services.isScopeMaskEnabled, @@ -1001,7 +1001,7 @@ export class StyleManager extends ShellScene { setEnabled: (next) => this._dataManager.setEnabled('cctv', next, { origin: 'user' }), readOwnership: () => ({ - trackedEntity: this.viewer?.trackedEntity, + trackedEntity: this.engine?.camera.following() ?? null, cockpitActive: !!this.cockpitView?.active, }), shouldFocus: () => diff --git a/src/ui/displayBindings.js b/src/ui/displayBindings.js index f97de4fb0..83be1bff9 100644 --- a/src/ui/displayBindings.js +++ b/src/ui/displayBindings.js @@ -6,7 +6,7 @@ import { applicationPinia } from './vueRuntime.js'; /** Own keyboard/display event subscriptions; settings remain with their state owners. */ export class DisplayBindings { constructor({ - viewer, + engine, services, elements, operations, @@ -16,7 +16,7 @@ export class DisplayBindings { }) { Object.assign( this, - { viewer, services, readState, claimDetection, displayStore }, + { engine, services, readState, claimDetection, displayStore }, elements, operations, ); @@ -45,7 +45,7 @@ export class DisplayBindings { _initUI() { const { cycleDetectionMode, isScopeMaskEnabled } = this.services; this._applicationShortcuts?.destroy(); - this.attachViewer(this.viewer); + this.attachEngine(this.engine); this._applicationShortcuts = bindApplicationShortcuts({ documentRef: document, searchInput: this._locationSearch, @@ -168,14 +168,14 @@ export class DisplayBindings { this._detachDisplayControls = () => store.detach(token); } /** - * Count the frames of another viewer (an engine switch), or none. The + * Count the frames of another engine (an engine switch), or none. The * shortcuts and the Display controls stay bound. */ - attachViewer(viewer) { - this.viewer = viewer ?? null; + attachEngine(engine) { + this.engine = engine ?? null; this._frameRateMonitor?.destroy(); this._frameRateMonitor = createFrameRateMonitor({ - viewer: this.viewer, + engine: this.engine, documentRef: document, }); } diff --git a/src/ui/frameRateMonitor.js b/src/ui/frameRateMonitor.js index 233041b08..ae516d2f1 100644 --- a/src/ui/frameRateMonitor.js +++ b/src/ui/frameRateMonitor.js @@ -6,11 +6,11 @@ import { applicationPinia } from './vueRuntime.js'; * readout is presented to the status store, which the title bar renders. */ export function createFrameRateMonitor({ - viewer, + engine, documentRef = document, store = useStatusStore(applicationPinia()), }) { - const frameEvent = viewer?.scene?.postRender; + const frameEvent = engine?.frames.postRender; // A page without the scene chrome has no title bar to show it in. if (!documentRef.getElementById('title-bar') || !frameEvent) return { destroy() {} }; diff --git a/src/ui/frameRateMonitor.test.mjs b/src/ui/frameRateMonitor.test.mjs index fcf482ce6..f5b120d01 100644 --- a/src/ui/frameRateMonitor.test.mjs +++ b/src/ui/frameRateMonitor.test.mjs @@ -17,8 +17,8 @@ function fixture(t) { addEventListener: (name, handler) => keys.add(handler), removeEventListener: (name, handler) => keys.delete(handler), }; - const viewer = { - scene: { + const engine = { + frames: { postRender: { addEventListener(handler) { frames.add(handler); @@ -27,7 +27,7 @@ function fixture(t) { }, }, }; - const monitor = createFrameRateMonitor({ viewer, documentRef, store }); + const monitor = createFrameRateMonitor({ engine, documentRef, store }); t.after(() => monitor.destroy()); return { monitor, @@ -108,7 +108,7 @@ test('a page without the title bar gets no readout and keeps the key', () => { let listened = false; const store = { publishFps: () => assert.fail('nothing to show it in') }; const monitor = createFrameRateMonitor({ - viewer: { scene: { postRender: { addEventListener: () => () => {} } } }, + engine: { frames: { postRender: { addEventListener: () => () => {} } } }, documentRef: { getElementById: () => null, addEventListener: () => (listened = true), @@ -130,11 +130,11 @@ test("destroying an earlier monitor leaves a newer one's readout", () => { addEventListener() {}, removeEventListener() {}, }; - const viewer = { - scene: { postRender: { addEventListener: () => () => {} } }, + const engine = { + frames: { postRender: { addEventListener: () => () => {} } }, }; - const first = createFrameRateMonitor({ viewer, documentRef, store }); - const second = createFrameRateMonitor({ viewer, documentRef, store }); + const first = createFrameRateMonitor({ engine, documentRef, store }); + const second = createFrameRateMonitor({ engine, documentRef, store }); const shown = store.fps; first.destroy(); assert.ok(store.fps === shown); diff --git a/src/ui/frameRateView.test.mjs b/src/ui/frameRateView.test.mjs index 7b25f7cb9..3e3537676 100644 --- a/src/ui/frameRateView.test.mjs +++ b/src/ui/frameRateView.test.mjs @@ -19,7 +19,7 @@ test('the FPS readout sits in the title bar, hidden until the backquote key', as document.querySelector('#title-bar .frame-rate-readout'); assert.equal(readout(), null, 'no readout without a monitor'); const monitor = createFrameRateMonitor({ - viewer: { scene: { postRender: { addEventListener: () => () => {} } } }, + engine: { frames: { postRender: { addEventListener: () => () => {} } } }, store: useStatusStore(pinia), }); t.after(() => monitor.destroy()); diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index 584c47700..467a87474 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -82,17 +82,16 @@ export class LayerBindings { window, this._worldRequestFocusHandler, ); - // Cesium's trackedEntity changes announce navigation through this event. - if (this.viewer) - this._navigationOwnerChangedRemover = - this.viewer.trackedEntityChanged.addEventListener((entity) => { - // Rebinding the session's subject (a resumed renderer, a share - // restore) is part of the pending restoration, not a new navigation - // that supersedes it. The mark lasts only while the follow starts. - if (entity && !this._disposed && !entity.gevRestoring) - this._stampNavigation({ cancelPendingSelection: false }); - }); - // Vessel/installation focus flies without ever assigning a tracked entity, + // A new follow announces navigation through this event. + this._navigationOwnerChangedRemover = + this.engine?.camera.followChanged.addEventListener((handle) => { + // Rebinding the session's subject (a resumed renderer, a share + // restore) is part of the pending restoration, not a new navigation + // that supersedes it. The mark lasts only while the follow starts. + if (handle && !this._disposed && !handle.native?.gevRestoring) + this._stampNavigation({ cancelPendingSelection: false }); + }) ?? null; + // Vessel/installation focus flies without ever starting a follow, // so it cannot reach the listener above. It announces instead. this._removeNavigationAuthorityListener = registerNavigationAuthorityListener(window, (event) => { diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 9ff9df2ce..68291f6fe 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -386,7 +386,6 @@ export class LocationNavigation { /* best-effort release */ } this.engine.camera.following()?.stop(); - if (this.viewer) this.viewer.trackedEntity = undefined; this.engine.camera.cancelFlight(); this.engine.camera.release(); this._beginWorldJumpTransition(); diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 703512e0e..53ac78426 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -185,7 +185,7 @@ export class NavigationController { } catch { /* best-effort release */ } - if (this.viewer) this.viewer.trackedEntity = undefined; + this.engine?.camera.following()?.stop(); this.interruptCameraMotion('explicit-navigation'); this.stopOrbit(); const camera = this.engine?.camera; diff --git a/src/ui/shellLifetime.test.mjs b/src/ui/shellLifetime.test.mjs index 746dc2c07..7f4afc24d 100644 --- a/src/ui/shellLifetime.test.mjs +++ b/src/ui/shellLifetime.test.mjs @@ -127,12 +127,12 @@ test('camera-entry listeners are removed before any asynchronous layer cleanup', const { owner } = bindings(t); let changes = 0; let trackedCallback; - owner.viewer = { trackedEntityChanged: { + owner.engine = { camera: { followChanged: { addEventListener(callback) { trackedCallback = callback; return () => { changes += 1; }; }, - } }; + } } }; owner._stampNavigation = () => { changes += 100; }; owner.observeCamera(); owner.stop(); diff --git a/src/ui/shellScene.js b/src/ui/shellScene.js index dfbeea84a..5e853672e 100644 --- a/src/ui/shellScene.js +++ b/src/ui/shellScene.js @@ -344,7 +344,7 @@ export class ShellScene extends ShellFacade { this._locationNavigation.attachScene({ viewer, engine }); this.hud.attachEngine(engine, { viewer }); this.shareLinkManager.attachEngine(engine); - this._displayBindings.attachViewer(viewer); + this._displayBindings.attachEngine(engine); this._buildSceneControls(); this._buildSceneCockpit(); this._shareRestoration.attachLinks(this.shareLinkManager); @@ -441,7 +441,7 @@ export class ShellScene extends ShellFacade { if (this._orientationToken) useGlobeActionsStore(applicationPinia()).detach(this._orientationToken); this._orientationToken = null; - this._displayBindings.attachViewer(null); + this._displayBindings.attachEngine(null); this._locationNavigation.detachScene(); this.hud.attachEngine(null); this.shareLinkManager.attachEngine(null); diff --git a/src/ui/shellSceneSwap.test.mjs b/src/ui/shellSceneSwap.test.mjs index 55dcbdb1f..93a7ed464 100644 --- a/src/ui/shellSceneSwap.test.mjs +++ b/src/ui/shellSceneSwap.test.mjs @@ -28,7 +28,7 @@ function fakeShell() { }, hud: { attachEngine: record('hud.attachEngine') }, shareLink: { attachEngine: record('shareLink.attachEngine') }, - display: { attachViewer: record('display.attachViewer') }, + display: { attachEngine: record('display.attachEngine') }, context: { canRetainScene: () => true, restoreForDisposal: record('context.restore'), @@ -124,7 +124,7 @@ test('a switch detaches the globe-bound parts and keeps the session objects', as 'visual.destroy', 'mapSource.destroy', 'orientation.destroy', - 'display.attachViewer', + 'display.attachEngine', 'location.detachScene', 'hud.attachEngine', 'shareLink.attachEngine', @@ -156,7 +156,7 @@ test('the next globe gets new globe-bound parts and the restore runs on it', asy 'location.attachScene', 'hud.attachEngine', 'shareLink.attachEngine', - 'display.attachViewer', + 'display.attachEngine', '_buildSceneControls', '_buildSceneCockpit', 'shareRestoration.attachLinks',