From 18f69c26997a0b9f3f144aa091bdebf76c86b078 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sun, 8 Nov 2026 17:05:27 +0400 Subject: [PATCH] Follow, credits, embed views and FPS through the engine on Navara Five places still read the Cesium viewer and so did nothing, or the wrong thing, on Navara. Each now goes through the globe contract, and Cesium behaves as before. - The Navara ground floor stands aside while the camera inputs are suspended, not only while they are switched off. The cockpit suspends them, so its eye 12 m up no longer jumps to the floor's 30 m between updates. A follow's own suspension keeps the floor on, as before. - Follow state comes from engine.camera.following() and followChanged instead of viewer.trackedEntity. Enabling CCTV during a follow no longer flies away and ends it, a new follow stamps navigation, Director cards wait while a subject is followed, and an explicit navigation stops the follow on both engines. - Navara registers every data credit, as Cesium does. The allowlist left out the layers that moved to Navara later (CCTV agencies, street-level imagery, GIBS, routing, bike feeds and others). - Annotation links and embed mode attach on every engine; their camera flights use engine.camera.flyTo. Only Cesium adds the timer that keeps an inline panel drawing. - The FPS readout counts engine.frames.postRender, so it shows on Navara. Each fix has a test that fails without it. --- src/app/embed.js | 26 ++++++++------ src/app/embed.test.mjs | 22 ++++++------ src/app/navaraMode.js | 36 ++------------------ src/app/navaraScene.test.mjs | 27 +++------------ src/app/tools.js | 33 ++++++++---------- src/app/viewerlessTools.test.mjs | 23 +++++++++++++ src/cameraHandoff.test.mjs | 8 ++--- src/globe/navara/createNavaraEngine.js | 9 +++-- src/globe/navara/createNavaraEngine.test.mjs | 15 ++++++++ src/layers/aircraft/sceneBinding.test.mjs | 20 +++++------ src/scenes/director.js | 2 +- src/scenes/director.test.mjs | 3 +- src/ui/applicationShell.js | 4 +-- src/ui/displayBindings.js | 14 ++++---- src/ui/frameRateMonitor.js | 4 +-- src/ui/frameRateMonitor.test.mjs | 16 ++++----- src/ui/frameRateView.test.mjs | 2 +- src/ui/layerBindings.js | 21 ++++++------ src/ui/locationNavigation.js | 1 - src/ui/navigationController.js | 2 +- src/ui/shellLifetime.test.mjs | 4 +-- src/ui/shellScene.js | 4 +-- src/ui/shellSceneSwap.test.mjs | 6 ++-- 23 files changed, 144 insertions(+), 158 deletions(-) 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',