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 }); } }