From d1fdb9eb451b9a21ebf0d5423f93e351699f8e46 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 17:48:08 +0400 Subject: [PATCH] Let the shell's share link, HUD, orbit and navigation follow another globe engine The shell reads its viewer, engine and map stack from the attached scene. ShareLinkManager, IntelHUD and OrbitController gain attachEngine, NavigationController setScene and LocationNavigation attachScene/detachScene, so a switch can keep them and move only their camera listeners; between engines they write and read nothing. The voice runner and session voice modules are owned by their packages, which check:boundaries needs since the voice commit. --- scripts/package-boundaries.json | 2 + src/cameraHandoff.test.mjs | 2 +- src/hud.js | 31 +++++++++++- src/orbit.js | 14 ++++++ src/sceneAttachEngine.test.mjs | 88 +++++++++++++++++++++++++++++++++ src/sharelink.js | 33 +++++++++++-- src/ui/applicationShell.js | 41 +++++++++++---- src/ui/locationNavigation.js | 24 +++++++++ src/ui/navigationController.js | 25 +++++++++- 9 files changed, 243 insertions(+), 17 deletions(-) create mode 100644 src/sceneAttachEngine.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d5d588d9d..79f49b275 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2660,6 +2660,7 @@ "src/app/stateChannel.js", "src/app/surfaceServices.js", "src/app/tools.js", + "src/app/sessionVoice.js", "src/app/viewer.js", "src/sky/starrySky.js", "src/sky/stars.js", @@ -3203,6 +3204,7 @@ "src/voice/voiceCardPresentation.js", "src/voice/resultDisplay.js", "src/voice/gevRealtime.js", + "src/voice/sceneRunner.js", "src/voice/layerSummary.js", "src/voice/cloudVoiceAuth.js", "src/voice/realtimeBackend.js", diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 589a69d70..d4f60e99f 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -306,7 +306,7 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () = 'this._navigation.destroy(', 'await this._contextControls.restoreForDisposal();', ], 'dispose invalidation'); - assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\{cancelPendingSelection: !retainScene\}\)/); + assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\{\s*cancelPendingSelection: !retainScene\s*\}\)/); }); test('teardown synchronously closes immediate camera entry points', () => { diff --git a/src/hud.js b/src/hud.js index 8a67168c1..9dd381f15 100644 --- a/src/hud.js +++ b/src/hud.js @@ -511,6 +511,8 @@ export class IntelHUD { * collection timestamp. Stores results in {@link _latestMetrics}. */ _updateCameraData() { + // Between two globe engines there is no camera to read. + if (!this.engine) return; const pose = this.engine.camera.pose(); const cartographic = pose.cartographic; if (!cartographic) return; @@ -563,7 +565,7 @@ export class IntelHUD { const sunEl = this._estimateSunElevation(latDeg, lonDeg); // i18n-ignore: sensor codes readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`; - if (this.engine.host.container?.hasAttribute?.('data-terrain-daylight')) + if (this.engine?.host.container?.hasAttribute?.('data-terrain-daylight')) readouts.alt += ' · DAYLIGHT VIEW'; // Collection timestamp @@ -740,7 +742,7 @@ export class IntelHUD { * dimensions, or null if the view rectangle cannot be computed. */ _viewWindowKm(latDeg) { - const rect = this.engine.camera.viewRectangle(); + const rect = this.engine?.camera.viewRectangle(); if (!rect) return null; const north = geo.math.toDegrees(rect.north); const south = geo.math.toDegrees(rect.south); @@ -1040,6 +1042,8 @@ export class IntelHUD { async _summaryContext() { const revision = this._summaryRevision; + // Between two globe engines the summary keeps its fallback line. + if (!this.engine) throw new Error('No globe camera to describe'); const labels = await getBasemapLabelContext(this.engine, this.placeSearch, { ...this.basemapContext, previous: this._placeLabels, @@ -1224,6 +1228,29 @@ export class IntelHUD { this._markSummaryDirty(); } + /** + * Read another globe engine's camera (an engine switch), or none (null) + * between two engines. Telemetry, summaries and the camera listeners + * follow it; the view, timers and data manager stay. + */ + attachEngine(engine, { viewer = null } = {}) { + this._removeCameraMoveEnd?.(); + this._removeCameraMoveEnd = null; + this._removeCameraMoveStart?.(); + this._removeCameraMoveStart = null; + this._summaryRequest?.abort(); + this.engine = engine ? engineFromViewer(engine) : null; + this.viewer = viewer; + if (!this.engine) return; + this._removeCameraMoveStart = + this.engine.camera.moveStart?.addEventListener(this._onCameraMoveStart); + this._removeCameraMoveEnd = this.engine.camera.moveEnd.addEventListener( + this._onCameraMoveEnd, + ); + this._markSummaryDirty(); + if (this._visible) this._updateCameraData(); + } + /** Tear down all running intervals. Call when discarding the HUD instance. */ destroy() { clearInterval(this._updateInterval); diff --git a/src/orbit.js b/src/orbit.js index 1fa204894..3806827ef 100644 --- a/src/orbit.js +++ b/src/orbit.js @@ -82,6 +82,20 @@ export class OrbitController { this.engine?.camera.release(); } + /** + * Orbit on another globe engine's camera (an engine switch), or none + * (null). A running orbit stops: its frames belonged to the outgoing one. + */ + attachEngine(engine) { + if (this.active || this._removeListener) { + this.active = false; + releaseContinuousRender('camera-orbit'); + this._removeListener?.(); + this._removeListener = null; + } + this.engine = engine ? engineFromViewer(engine) : null; + } + /** * Toggle orbit on/off. * @param {{x: number, y: number, z: number}} targetCartesian - Required when starting diff --git a/src/sceneAttachEngine.test.mjs b/src/sceneAttachEngine.test.mjs new file mode 100644 index 000000000..552214c60 --- /dev/null +++ b/src/sceneAttachEngine.test.mjs @@ -0,0 +1,88 @@ +// The session objects an engine switch keeps follow the next globe's camera +// (attachEngine / setScene) instead of being rebuilt, and hold no listener on +// the globe they left. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createFakeEngine } from './globe/testing/fakeEngine.js'; +import { ShareLinkManager } from './sharelink.js'; +import { OrbitController } from './orbit.js'; +import { NavigationController } from './ui/navigationController.js'; + +const listeners = (event) => event.numberOfListeners; + +test('the share link follows the attached camera and writes nothing between engines', (t) => { + const prior = { window: globalThis.window, history: globalThis.history }; + const writes = []; + globalThis.window = Object.assign(new EventTarget(), { + location: new URL('http://localhost/'), + }); + globalThis.history = { replaceState: (...args) => writes.push(args) }; + t.after(() => Object.assign(globalThis, prior)); + const first = createFakeEngine(); + const second = createFakeEngine(); + const manager = new ShareLinkManager(first); + assert.equal(listeners(first.camera.changed), 1); + manager.attachEngine(null); + assert.equal(listeners(first.camera.changed), 0); + assert.equal(manager.engine, null); + manager._updateHash(); + assert.equal(writes.length, 0, 'no camera, no address write'); + assert.equal(manager.buildLink(), null); + manager.attachEngine(second); + assert.equal(listeners(second.camera.changed), 1); + assert.equal(manager.engine, second); + manager.destroy(); + assert.equal(listeners(second.camera.changed), 0); +}); + +test('an orbit stops when its globe leaves and orbits the next one', () => { + const first = createFakeEngine(); + const second = createFakeEngine(); + const orbit = new OrbitController(first); + orbit.start({ x: 6378137, y: 0, z: 0 }); + assert.equal(orbit.active, true); + assert.equal(listeners(first.frames.preRender), 1); + orbit.attachEngine(second); + assert.equal(orbit.active, false); + assert.equal(listeners(first.frames.preRender), 0); + orbit.start({ x: 6378137, y: 0, z: 0 }); + assert.equal(listeners(second.frames.preRender), 1); + orbit.stop(); +}); + +test('navigation authority retires deferred flights when the globe changes', () => { + const first = createFakeEngine(); + const second = createFakeEngine(); + const none = { + cancelPendingTrackingRestore() {}, + getTrackedInfo: () => null, + }; + const navigation = new NavigationController({ + engine: first, + tracking: { + flightsLayer: none, + militaryFlightsLayer: none, + satellitesLayer: none, + }, + interruptCameraMotion() {}, + isCockpitActive: () => false, + clearLocation() {}, + cancelShareSelection: () => false, + getDataManager: () => null, + stopOrbit() {}, + showToast() {}, + }); + const generation = navigation._beginDeferredNavigation('shared view'); + assert.equal(Number.isInteger(generation), true); + navigation.setScene({ engine: null }); + assert.equal(navigation.engine, null); + assert.equal( + navigation._reassertNavigationHandoff(generation), + false, + 'a flight deferred on the outgoing globe cannot land on the next one', + ); + navigation.setScene({ engine: second }); + assert.equal(navigation.engine, second); + const next = navigation._beginDeferredNavigation('location'); + assert.equal(navigation._reassertNavigationHandoff(next), true); +}); diff --git a/src/sharelink.js b/src/sharelink.js index 9006daf5c..35dbda39b 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -150,6 +150,28 @@ export class ShareLinkManager { ); } + /** + * Follow another globe engine's camera (an engine switch), or none (null) + * between two engines. The link's state, listeners and holds stay; a + * restore flight still owned on the outgoing camera is settled. + */ + attachEngine(engine) { + if (this._destroyed) return; + this._removeCameraChanged?.(); + this._removeCameraChanged = null; + if (this._activeCameraFlight) { + const flight = this._activeCameraFlight; + this._restoreGeneration += 1; + this._activeCameraFlight = null; + flight.settle?.('superseded'); + } + this.engine = engine ? engineFromViewer(engine) : null; + if (!this.engine) return; + this._removeCameraChanged = this.engine.camera.changed.addEventListener( + () => this._scheduleUpdate(), + ); + } + /** * Parse the share this page opened with: the host's `initialShareCode`, a * `/share/` address, or a long-form hash. Returns the parsed state, or @@ -364,7 +386,7 @@ export class ShareLinkManager { state, { applyCamera = true, instant = false, navigationToken = null } = {}, ) { - if (this._destroyed || !state) + if (this._destroyed || !state || !this.engine) return { succeeded: false, reason: 'unavailable' }; const view = { destination: geo.cartesian3.fromDegrees(state.lon, state.lat, state.alt), @@ -682,7 +704,12 @@ export class ShareLinkManager { } _updateHash() { - if (this._destroyed || this._initialRestorePending || this._navigationHeld) + if ( + this._destroyed || + !this.engine || + this._initialRestorePending || + this._navigationHeld + ) return; const params = this._buildHashParams(); if (!params) return; @@ -703,7 +730,7 @@ export class ShareLinkManager { /** Build a deterministic snapshot without mutating history. */ _buildHashParams() { - if (this._destroyed) return null; + if (this._destroyed || !this.engine) return null; const pose = this.engine.camera.pose(); const carto = pose.cartographic; if (!carto) return null; diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index acd3e43c1..47dc033ba 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -168,9 +168,9 @@ export class StyleManager extends ShellFacade { this._feedback = new ShellFeedback({ readLayers: () => this._dataManager?.getAll?.() || [], }); - this.viewer = viewer; - this.engine = engine; - this.mapStackController = mapStackController; + // The attached globe: its viewer (null on Navara), engine and map + // stack. An engine switch replaces it (attachScene/detachScene). + this._scene = { viewer, engine, mapStackController }; this.placeSearch = placeSearch; this._navigation = new NavigationController({ @@ -458,7 +458,7 @@ export class StyleManager extends ShellFacade { }, isNavigationCurrent: (generation) => generation === this._navigationGeneration, - cancelOwnedNavigation: () => this.engine.camera.cancelFlight(), + cancelOwnedNavigation: () => this.engine?.camera.cancelFlight(), // A host may open a share code without a `/share/` address. initialShareCode, }); @@ -625,7 +625,10 @@ export class StyleManager extends ShellFacade { this._publishActiveStyle(); this._updateLocationMiniStatus(); - this._shareRestoration.start({ engineSwitch, retainScene: Boolean(this._layerSession?.handoffLayers) }); + this._shareRestoration.start({ + engineSwitch, + retainScene: Boolean(this._layerSession?.handoffLayers), + }); // Keep the parameter panel from overlapping toggle controls. this._layoutRightPanels(); @@ -644,6 +647,21 @@ export class StyleManager extends ShellFacade { this._layerBindings.observeCamera(); } + /** The attached globe's Cesium viewer; null on Navara or between engines. */ + get viewer() { + return this._scene?.viewer ?? null; + } + + /** The attached globe engine; null between engines. */ + get engine() { + return this._scene?.engine ?? null; + } + + /** The attached globe's map stack; null between engines. */ + get mapStackController() { + return this._scene?.mapStackController ?? null; + } + // Compatibility reads for existing controls, scene snapshots and Cockpit. /** Advance camera authority and settle any older search UI immediately. */ @@ -1475,7 +1493,7 @@ export class StyleManager extends ShellFacade { * @returns {void} */ applyCameraState(cameraState, duration = 2.8) { - if (!cameraState) return; + if (!cameraState || !this.engine) return; this.engine.camera.flyTo({ destination: geo.cartesian3.fromDegrees( cameraState.lon, @@ -1856,12 +1874,17 @@ export class StyleManager extends ShellFacade { // re-enable a mode's entry layer and republish `_contextMode` while the // rest of teardown is tearing those very layers down. this._contextControls.stop(); - this._navigation.destroy({ retainScene: Boolean(this._layerSession?.handoffLayers) }); + this._navigation.destroy({ + retainScene: Boolean(this._layerSession?.handoffLayers), + }); // Close camera-entry seams synchronously. Context restoration may await // layer work, so leaving these listeners attached until afterward lets a // focus event release tracking or start a flight during teardown. - if (this._layerSession && !this._layerSession.lifetime.signal.aborted && - this._contextControls.canRetainScene()) { + if ( + this._layerSession && + !this._layerSession.lifetime.signal.aborted && + this._contextControls.canRetainScene() + ) { this._layerSession.contextScene = this._contextControls.captureScene(); } else { if (this._layerSession) this._layerSession.contextScene = null; diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 24198c173..0fed31d95 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -443,6 +443,30 @@ export class LocationNavigation { } /** Revoke callbacks and settle owned camera work before the viewer is released. */ + /** + * Leave the globe (an engine switch): its reset flight, world-jump + * transition and orbit end; the search, its text and the readout stay. + */ + detachScene() { + this._cancelGlobeReset?.(); + this._cancelGlobeReset = null; + if (this._worldJumpActive) this._endWorldJumpTransition(); + clearTimeout(this._trafficTransitionTimer); + this._trafficTransitionTimer = null; + this._stopOrbit(); + this.orbitController?.attachEngine(null); + this.viewer = null; + this.engine = null; + } + + /** Navigate on another globe (an engine switch). */ + attachScene({ viewer = null, engine = engineFromViewer(viewer) } = {}) { + if (this._disposed) return; + this.viewer = viewer; + this.engine = engine; + this.orbitController?.attachEngine(engine); + } + destroy() { if (this._disposed) return; this._disposed = true; diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index be23c53c4..9d1eca686 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -254,7 +254,10 @@ export class NavigationController { }); } - _reassertNavigationHandoff(generation, { trackingOrigin = 'tool', keepTracking = false } = {}) { + _reassertNavigationHandoff( + generation, + { trackingOrigin = 'tool', keepTracking = false } = {}, + ) { return reassertNavigationHandoff({ generation, currentGeneration: this._navigationGeneration, @@ -270,6 +273,24 @@ export class NavigationController { }, }); } + /** + * The globe the camera authority applies to (an engine switch), or none. + * Leaving a globe retires every deferred navigation started on it: its + * flight cannot land on the next one. `retainScene` keeps the selection a + * handoff carries across. + */ + setScene({ viewer = null, engine = null } = {}, { retainScene = true } = {}) { + if (this._disposed) return; + if (this.engine && engine !== this.engine) { + cancelCameraArrival(this.engine); + this._stampNavigation({ + cancelPendingSelection: !retainScene, + clearSearchedLocation: false, + }); + } + this.viewer = viewer; + this.engine = engine ?? engineFromViewer(viewer); + } /** Subscribe to ownership changes without claiming the camera or exposing mutable state. */ subscribeCameraHandoff(listener) { return this._cameraHandoffs.subscribe(listener, { emitCurrent: false }); @@ -282,6 +303,6 @@ export class NavigationController { } destroy({ retainScene = false } = {}) { this.stop(); - this._stampNavigation({cancelPendingSelection: !retainScene}); + this._stampNavigation({ cancelPendingSelection: !retainScene }); } }