diff --git a/docs/DIRECTOR-DATA-PACKS.md b/docs/DIRECTOR-DATA-PACKS.md index bf64efd0b..df18b167a 100644 --- a/docs/DIRECTOR-DATA-PACKS.md +++ b/docs/DIRECTOR-DATA-PACKS.md @@ -90,7 +90,7 @@ load, removes partial results and reports a stable error without exposing a URL. Successful loading leaves the attribution cards visible with the assets. `createApplicationTools({ sceneDataPacks: { sources } })` and -`new SceneDirector(viewer, style, data, { dataPacks: { sources } })` accept a +`new SceneDirector(scene, style, data, { dataPacks: { sources } })` accept a registry of source functions. Each receives `{ path, signal, maxBytes }` and returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the signal, bound reads and return the real MIME type. They remain application code, diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index e970aabfb..09f9ae3ec 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -3,7 +3,7 @@ "cesiumLayerRendererLines": 14, "navaraLayerRendererLines": 314, "cesiumImportFiles": 5, - "viewerLines": 948, + "viewerLines": 933, "navaraKindChecks": 2, "engineSniffCalls": 0, "engineExtensionReads": 4, @@ -12,5 +12,5 @@ "screenSpaceHandlerFiles": 4, "engineNameLines": 36 }, - "measuredAt": "Draw Bhote Koshi story graphics and range pulse fields through engine.ground" + "measuredAt": "Separate scene attachment and page switching; give Director the scene" } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e8aa4cc75..512bb59b3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1692,5 +1692,11 @@ "src/globe/groundPulse.test.mjs", "src/globe/navara/ground/pulse.test.mjs", "src/layers/bhoteKoshi/render/story.test.mjs", - "src/globe/navara/windFlowMesh.js" + "src/globe/navara/windFlowMesh.js", + "src/data/layerSceneBinding.js", + "src/ui/shellScene.js", + "src/app/layerSession.js", + "src/standalone/sceneSwitcher.js", + "src/app/layerSession.test.mjs", + "src/standalone/sceneSwitcher.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 59d6042ee..da486ec20 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1007,6 +1007,8 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/app/layerSession.js", + "src/ui/shellScene.js", "src/ui/localePicker.js", "src/ui/cyberSonarControls.js", "src/ui/locationNavigation.js", @@ -2540,6 +2542,7 @@ "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/lifecycle.js", + "src/data/layerSceneBinding.js", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", "src/data/local_data/natural_earth/marine.json", @@ -2881,6 +2884,8 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/app/layerSession.js", + "src/ui/shellScene.js", "src/ui/mobileShell.js", "src/ui/localePicker.js", "src/ui/cyberSonarControls.js", @@ -4457,6 +4462,7 @@ "src/globe/cesium/engineFromViewer.js", "src/globe/contract.js", "src/data/lifecycle.js", + "src/data/layerSceneBinding.js", "src/data/cameraArrival.js" ], "external": [] diff --git a/scripts/qa-director-interactions.mjs b/scripts/qa-director-interactions.mjs index 829ff06e8..a6d3cb83a 100644 --- a/scripts/qa-director-interactions.mjs +++ b/scripts/qa-director-interactions.mjs @@ -225,8 +225,7 @@ try { await page.evaluate( () => Math.abs( - window.__godsEyeView.sceneDirector.viewer.camera.positionCartographic - .height - 5000, + window.__godsEyeView.sceneDirector.engine.camera.height() - 5000, ) < 1, ), ); diff --git a/src/app/controls.test.mjs b/src/app/controls.test.mjs index ba4baf1f8..a37a60a13 100644 --- a/src/app/controls.test.mjs +++ b/src/app/controls.test.mjs @@ -30,7 +30,7 @@ test('the controls pass the scene engine to the shell', () => { }); test('the shell initializes the world overlay on its engine', () => { - const shell = read('../ui/applicationShell.js'); + const shell = read('../ui/applicationShell.js') + read('../ui/shellScene.js'); assert.match(shell, /\{\n\s+engine = null,\n\s+mapStackController = null,/); assert.match(shell, /initWorldOverlay\(engine\);/); assert.doesNotMatch(shell, /initWorldOverlay\(viewer\)/); diff --git a/src/app/layerSession.js b/src/app/layerSession.js new file mode 100644 index 000000000..ead392243 --- /dev/null +++ b/src/app/layerSession.js @@ -0,0 +1,49 @@ +/** + * State that survives replacement of the globe. Each field names its owner; + * scene attachments keep their own signals and never replace this lifetime. + * + * @typedef {object} LayerSession + * @property {AbortController} lifetime Page owner: ends retained work. + * @property {Map} models Catalog: retained layer modules. + * @property {import('../data/lifecycle.js').LayerLifecycle | null} manager Data phase. + * @property {import('./layerPresentation.js').LayerPresentation | null} presentation Data phase. + * @property {ReturnType | null} operations Scene factory: session requests and surface caches. + * @property {ReturnType | null} militaryRegistry Catalog. + * @property {ReturnType | null} weatherClock Catalog. + * @property {import('../scenes/director.js').SceneDirector | null} director Tools phase. + * @property {ReturnType | null} annotations Session tools. + * @property {{draw?: ReturnType, geoImport?: ReturnType}} tools Session tools. + * @property {ReturnType | null} voice Session voice. + * @property {{locale?: () => void, placeSearch?: object, sceneChrome?: boolean}} chrome Standalone startup and scene factory. + * @property {Map | null} handoffLayers Switch coordinator: outgoing layer intentions. + * @property {boolean} restoringView Switch coordinator: incoming address restoration. + * @property {() => boolean} switching Switch coordinator: voice waits while busy. + * @property {object | null} contextScene Shell: retained Context state. + */ + +/** @returns {LayerSession} */ +export function createLayerSession() { + return { + lifetime: new AbortController(), + models: new Map(), + manager: null, + presentation: null, + operations: null, + militaryRegistry: null, + weatherClock: null, + director: null, + annotations: null, + tools: {}, + voice: null, + chrome: {}, + handoffLayers: null, + restoringView: false, + switching: () => false, + contextScene: null, + }; +} + +/** Whether teardown preserves the session for another globe. */ +export function retainsLayerSession(session) { + return Boolean(session?.handoffLayers && !session.lifetime.signal.aborted); +} diff --git a/src/app/layerSession.test.mjs b/src/app/layerSession.test.mjs new file mode 100644 index 000000000..200332186 --- /dev/null +++ b/src/app/layerSession.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createLayerSession, retainsLayerSession } from './layerSession.js'; +import { handOffSession, resumeSession } from '../standalone/engineSwitch.js'; + +test('a session retains layer intentions through handoff but never after page teardown', () => { + const session = createLayerSession(); + const other = createLayerSession(); + const calls = []; + const entry = { + module: { + attachRenderer() {}, + resumeViewport: () => calls.push('viewport'), + }, + }; + session.models.set('flights', entry.module); + session.manager = { + layers: new Map([['flights', entry]]), + isEffectivelyEnabled: () => true, + }; + session.director = { + suspendScene: () => calls.push('suspend'), + resumeScene: () => calls.push('resume'), + }; + assert.equal(retainsLayerSession(session), false); + handOffSession(session, { addressLayerIds: ['flights'] }); + assert.equal(retainsLayerSession(session), true); + assert.deepEqual(session.handoffLayers.get('flights'), { + entry, + enabled: true, + durable: true, + }); + resumeSession(session); + assert.deepEqual(calls, ['suspend', 'viewport', 'resume']); + assert.equal(session.models.get('flights'), entry.module); + assert.equal(other.models.size, 0); + assert.notEqual(session.tools, other.tools); + assert.notEqual(session.chrome, other.chrome); + session.lifetime.abort(); + assert.equal(retainsLayerSession(session), false); + assert.equal(other.lifetime.signal.aborted, false); + assert.equal(retainsLayerSession(null), false); +}); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 1a81fa8ab..5b505fde4 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -148,6 +148,7 @@ export function navaraToolBackend({ engine }) { const views = engine.extensions?.navara ?? null; const drawn = Boolean(views?.view); return { + startupChrome: { initializeWelcome: null }, board: () => drawn ? { engine, renderer: createNavaraAnnotationRenderer(engine, views) } diff --git a/src/app/tools.js b/src/app/tools.js index f74ac48e3..fca4d0519 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -1,3 +1,4 @@ +import { retainsLayerSession } from './layerSession.js'; import { SceneDirector } from '../scenes/director.js'; import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js'; @@ -73,8 +74,7 @@ export async function createApplicationTools({ mapStackController?.isStackAvailable(id) === true; const sceneDirector = layerSession?.director ?? - new SceneDirector(first.viewer, styleManager, dataManager, { - engine: first.engine, + new SceneDirector(first, styleManager, dataManager, { dataPacks: sceneDataPacks, isMapStackAvailable: isStackAvailable(first.mapStackController), }); @@ -85,7 +85,7 @@ export async function createApplicationTools({ .get('bhote-koshi-2026') ?.module.attachSceneController(sceneDirector); defer(() => { - if (layerSession?.handoffLayers && !layerSession.lifetime.signal.aborted) { + if (retainsLayerSession(layerSession)) { sceneDirector.suspendScene(); } else { if (layerSession) layerSession.director = null; @@ -123,8 +123,7 @@ export async function createApplicationTools({ if (next === directorScene) return; directorScene = null; sceneDirector.attachScene( - next.viewer, - next.engine, + next, styleManager, isStackAvailable(next.mapStackController), ); @@ -160,6 +159,7 @@ export async function createApplicationTools({ // The attached engine's tools; `annotations` and the voice handle answer // for the engine attached now. const attached = { annotations: null, voiceCommands: null }; + let startupChromeOptions; const context = { controls, data, @@ -177,6 +177,7 @@ export async function createApplicationTools({ name: 'tools', async attachScene(next, attachment) { const tools = await attachEngineTools(next, context, attachment); + startupChromeOptions ??= tools.startupChrome; attached.annotations = tools.annotations; attached.voiceCommands = tools.voiceCommands; attachment.defer(() => { @@ -220,12 +221,10 @@ export async function createApplicationTools({ { defer }, ); detachPartsOnTeardown(scene, defer, () => ({ - handoff: Boolean( - layerSession?.handoffLayers && !layerSession.lifetime.signal.aborted, - ), + handoff: retainsLayerSession(layerSession), })); - // The startup chrome belongs to the page: no first-run welcome on Navara - // (its choices turn on data layers). + // Startup chrome belongs to the page and keeps the first backend + // policy across later engine attachments. if (startChrome) defer( startChrome({ @@ -233,7 +232,7 @@ export async function createApplicationTools({ styleManager, dataManager, signal, - ...(first.viewer ? {} : { initializeWelcome: null }), + ...startupChromeOptions, }), ); return { @@ -269,6 +268,7 @@ function cesiumToolBackend({ engine, tileset }) { const viewer = cesiumViewerOf(engine); return { viewer, + startupChrome: {}, board: () => ({ viewer, engine, tileset }), draw: () => ({ engine }), // The tileset lets the layer drape while the globe is hidden. @@ -465,7 +465,7 @@ async function attachEngineTools( { styleManager, dataManager, voiceCommands }, { signal, defer }, ); - return { annotations, voiceCommands }; + return { annotations, voiceCommands, startupChrome: backend.startupChrome }; } /** The annotation board drawn on this globe (`boardScene`), or null. */ diff --git a/src/data/layerSceneBinding.js b/src/data/layerSceneBinding.js new file mode 100644 index 000000000..f621e9ca2 --- /dev/null +++ b/src/data/layerSceneBinding.js @@ -0,0 +1,259 @@ +// A scene bind or detach waits this long for asynchronous layer attachments. +// A late attachment sees its scene signal aborted and releases its own work. +export const ATTACH_SETTLE_TIMEOUT_MS = 5000; + +/** Scene attachment and parked work for the layer lifecycle. */ +export class LayerSceneBinding { + constructor(attachSettleTimeoutMs) { + // The bound scene: its signal reaches every attachRenderer() call and is + // aborted when the scene detaches. Asynchronous attachments still running + // are kept here so the detach releases them only after they stopped. + this._failedReleases = new WeakMap(); + this._sceneController = null; + this._sceneDetached = false; + this._pendingAttachments = new Set(); + this._attachSettleTimeoutMs = attachSettleTimeoutMs; + // Lifecycle work and explicit refreshes that wait for the next scene. + // detachScene() does not wait for parked work, which waits for it. + this._sceneWaiters = new Set(); + this._parkListeners = new Set(); + } + + /** + * Release the scene only. Models with an explicit renderer lifetime keep + * their entry, polling interval, visibility intents and in-flight source. + * Legacy modules use their ordinary teardown until they gain that contract. + * + * Every layer is released even when another one fails, and the manager is + * always left unbound, so the next bindCatalog() can attach a scene. The + * failures are reported together. A layer whose release failed keeps it + * pending: a repeated detachScene(), or the next bindCatalog(), retries it. + * Periodic refreshes, explicit refreshes and enables requested while no + * scene is bound wait for the next one. + */ + async detachScene() { + // Attachments still in flight stop at their next checkpoint; their + // partial work is released below like a finished attachment. + this._sceneController?.abort(); + this._sceneController = null; + this._sceneDetached = true; + const viewer = this.viewer; + const errors = []; + const droppedQaLayers = []; + try { + await this._awaitAttachments([...this._pendingAttachments], 'detach'); + for (const [id, entry] of [...this.layers]) { + try { + await this._lifecycleIdleOrParked(entry); + if (entry.module.attachRenderer && entry.module.detachRenderer) { + await this._detachEntryRenderer(entry, viewer); + } else { + const qaLayer = this._qaLayerIds.has(id); + if (!(await this.destroyLayer(id))) { + // The next catalog brings a fresh module for this id. A stale + // entry would refuse it, so the failed one is dropped here. + this._stopUpdateLoop(entry); + this.layers.delete(id); + this._qaLayerIds.delete(id); + throw new Error(`Cannot detach layer ${id}`); + } + if (qaLayer) droppedQaLayers.push(id); + } + } catch (error) { + errors.push(error); + } + } + } finally { + this.viewer = null; + this.engine = null; + this._registrationsFinalized = false; + this._registrationDispositions = null; + } + if (droppedQaLayers.length) + console.warn( + `[Data] QA layers do not survive a scene switch; register them again: ${droppedQaLayers.join(', ')}`, + ); + if (errors.length) { + for (const error of errors) + console.warn('[Data] scene detach error:', error); + throw new AggregateError(errors, 'Data layers could not detach'); + } + } + + /** Release one retained layer's renderer, keeping a failure for a retry. */ + async _detachEntryRenderer(entry, viewer) { + const pending = this._failedReleases.get(entry); + if (!pending && (!entry.initialized || entry.sceneDetached)) return; + const target = pending ? pending.target : viewer; + this._failedReleases.set(entry, { target }); + await entry.module.detachRenderer(target); + this._failedReleases.delete(entry); + entry.sceneDetached = true; + } + + /** Bind the next catalog only after the preceding scene released its entries. */ + bindCatalog(viewer, engine, catalog, options = {}) { + if (this._registrationsFinalized) throw new Error('Scene already attached'); + this.viewer = viewer; + this.engine = engine ?? null; + this._catalogSourceAvailability = catalog.getSourceAvailability; + const scene = new AbortController(); + this._sceneController = scene; + // An attachment that is still running when the scene detaches sees this + // signal aborted and must stop touching the scene. + const context = { ...this._moduleContext(scene.signal), ...options }; + const attachments = []; + for (const layer of catalog.layers) { + const entry = this.layers.get(layer.id); + if (!entry) this.register(layer); + else { + if (entry.module !== layer) + throw new Error(`Layer owner changed: ${layer.id}`); + if (entry.initialized) { + attachments.push(this._attachEntryRenderer(entry, viewer, context)); + } + } + } + // Retained entries may appear anywhere in the directory. Preserve both + // catalog order (panel/serialization) and each live entry's identity. + for (const { id } of catalog.layers) { + const entry = this.layers.get(id); + this.layers.delete(id); + this.layers.set(id, entry); + } + this.finalizeRegistrations(catalog.metadata); + const pending = attachments.filter((ready) => ready?.then); + // The scene counts as bound for refreshes and parked enables once its + // attachments settled (or the wait for them timed out). + const bound = () => { + if (this._sceneController !== scene) return; + this._sceneDetached = false; + this._runDeferredRefreshes(); + this._settleSceneWaiters(true); + }; + if (!pending.length) { + bound(); + return undefined; + } + return this._awaitAttachments(pending, 'bind').then((settled) => { + bound(); + const failure = settled?.find(({ status }) => status === 'rejected'); + if (failure) throw failure.reason; + }); + } + + /** + * Wait for attachments to settle, at most attachSettleTimeoutMs. Returns + * their allSettled results, or null when the wait timed out. + */ + async _awaitAttachments(attachments, phase) { + if (!attachments.length) return []; + let timer; + const timedOut = new Promise((resolve) => { + timer = setTimeout(resolve, this._attachSettleTimeoutMs, null); + }); + const settled = await Promise.race([ + Promise.allSettled(attachments), + timedOut, + ]); + clearTimeout(timer); + if (!settled) + console.warn( + `[Data] layer attachments still running after ${this._attachSettleTimeoutMs} ms; scene ${phase} continues without them`, + ); + return settled; + } + + /** + * Wait until a scene is bound. Resolves true then, or false when the + * signal aborts or the entry is destroyed. `refresh` marks an explicit + * refresh, which replaces the entry's deferred periodic one. + */ + _waitForScene(entry, signal = null, { refresh = false } = {}) { + if (!this._sceneDetached) return Promise.resolve(true); + if (signal?.aborted) return Promise.resolve(false); + return new Promise((resolve) => { + const onAbort = () => waiter.settle(false); + const waiter = { + entry, + refresh, + settle: (bound) => { + if (!this._sceneWaiters.delete(waiter)) return; + if (refresh) entry.sceneRefreshWaiters -= 1; + signal?.removeEventListener?.('abort', onAbort); + resolve(bound); + }, + }; + if (refresh) entry.sceneRefreshWaiters += 1; + this._sceneWaiters.add(waiter); + signal?.addEventListener?.('abort', onAbort, { once: true }); + for (const listener of [...this._parkListeners]) listener(entry); + }); + } + + _settleSceneWaiters(bound, entry = null) { + for (const waiter of [...this._sceneWaiters]) + if (!entry || waiter.entry === entry) waiter.settle(bound); + } + + /** The entry's queued lifecycle work settled, or is parked for a scene. */ + _lifecycleIdleOrParked(entry) { + const parked = () => + [...this._sceneWaiters].some( + (waiter) => waiter.entry === entry && !waiter.refresh, + ); + if (parked()) return Promise.resolve(); + return new Promise((resolve) => { + const done = () => { + this._parkListeners.delete(listener); + resolve(); + }; + const listener = (parkedEntry) => { + if (parkedEntry === entry && parked()) done(); + }; + this._parkListeners.add(listener); + entry.toggleChain.catch(() => {}).then(done); + }); + } + + /** + * Attach one retained layer to the bound scene. A release that failed on + * the previous scene is retried first. Asynchronous attachments are tracked + * so detachScene() releases them only after they stopped. + */ + _attachEntryRenderer(entry, viewer, context) { + let ready; + try { + const pending = this._failedReleases.get(entry); + entry.sceneDetached = false; + if (pending) { + ready = Promise.resolve() + .then(() => entry.module.detachRenderer(pending.target)) + .then(() => { + this._failedReleases.delete(entry); + if (context.signal.aborted) return undefined; + return entry.module.attachRenderer(viewer, context); + }); + } else ready = entry.module.attachRenderer(viewer, context); + } catch (error) { + ready = Promise.reject(error); + } + if (!ready?.then) return ready; + const tracked = Promise.resolve(ready); + this._pendingAttachments.add(tracked); + const untrack = () => this._pendingAttachments.delete(tracked); + tracked.then(untrack, untrack); + return tracked; + } + + /** Run the periodic refreshes that came due while no scene was bound. */ + _runDeferredRefreshes() { + for (const [layerId, entry] of this.layers) { + if (!entry.refreshDeferred) continue; + entry.refreshDeferred = false; + // An explicit refresh waiting for this scene runs instead. + if (entry.sceneRefreshWaiters > 0) continue; + void this._runPeriodicUpdate(layerId, entry); + } + } +} diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 309641783..3ccb67950 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -1,3 +1,7 @@ +import { + LayerSceneBinding, + ATTACH_SETTLE_TIMEOUT_MS, +} from './layerSceneBinding.js'; import { cancelCameraArrival } from './cameraArrival.js'; function cloneLayerParams(value) { @@ -14,9 +18,6 @@ function cloneLayerParams(value) { } const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent'); -// A scene bind or detach waits this long for asynchronous layer attachments. -// A late attachment sees its scene signal aborted and releases its own work. -const ATTACH_SETTLE_TIMEOUT_MS = 5000; const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([ 'enabled-only', 'enabled+options', @@ -78,7 +79,7 @@ function refreshFailureFromStats(stats, label) { * Hosts may inject a visibilityTarget with hidden/visibilityState and event * listener methods. Without one, periodic work retains its headless behavior. */ -export class LayerLifecycle { +export class LayerLifecycle extends LayerSceneBinding { /** * @param {object | null} viewer The Cesium viewer (null on Navara). * @param {{ engine?: import('../globe/contract.js').GlobeEngine, @@ -98,6 +99,7 @@ export class LayerLifecycle { attachSettleTimeoutMs = ATTACH_SETTLE_TIMEOUT_MS, } = {}, ) { + super(attachSettleTimeoutMs); this._catalogSourceAvailability = getSourceAvailability; this._visibilityTarget = visibilityTarget; this.viewer = viewer; @@ -112,17 +114,6 @@ export class LayerLifecycle { this._registrationDispositions = null; this._allowQaRegistration = allowQaRegistration === true; this._qaLayerIds = new Set(); - // The bound scene: its signal reaches every attachRenderer() call and is - // aborted when the scene detaches. Asynchronous attachments still running - // are kept here so the detach releases them only after they stopped. - this._sceneController = null; - this._sceneDetached = false; - this._pendingAttachments = new Set(); - this._attachSettleTimeoutMs = attachSettleTimeoutMs; - // Lifecycle work and explicit refreshes that wait for the next scene. - // detachScene() does not wait for parked work, which waits for it. - this._sceneWaiters = new Set(); - this._parkListeners = new Set(); } /** The second argument of every layer module call: `{ signal, engine }`. */ @@ -137,244 +128,6 @@ export class LayerLifecycle { this._registerLayer(layerModule); } - /** - * Release the scene only. Models with an explicit renderer lifetime keep - * their entry, polling interval, visibility intents and in-flight source. - * Legacy modules use their ordinary teardown until they gain that contract. - * - * Every layer is released even when another one fails, and the manager is - * always left unbound, so the next bindCatalog() can attach a scene. The - * failures are reported together. A layer whose release failed keeps it - * pending: a repeated detachScene(), or the next bindCatalog(), retries it. - * Periodic refreshes, explicit refreshes and enables requested while no - * scene is bound wait for the next one. - */ - async detachScene() { - // Attachments still in flight stop at their next checkpoint; their - // partial work is released below like a finished attachment. - this._sceneController?.abort(); - this._sceneController = null; - this._sceneDetached = true; - const viewer = this.viewer; - const errors = []; - const droppedQaLayers = []; - try { - await this._awaitAttachments([...this._pendingAttachments], 'detach'); - for (const [id, entry] of [...this.layers]) { - try { - await this._lifecycleIdleOrParked(entry); - if (entry.module.attachRenderer && entry.module.detachRenderer) { - await this._detachEntryRenderer(entry, viewer); - } else { - const qaLayer = this._qaLayerIds.has(id); - if (!(await this.destroyLayer(id))) { - // The next catalog brings a fresh module for this id. A stale - // entry would refuse it, so the failed one is dropped here. - this._stopUpdateLoop(entry); - this.layers.delete(id); - this._qaLayerIds.delete(id); - throw new Error(`Cannot detach layer ${id}`); - } - if (qaLayer) droppedQaLayers.push(id); - } - } catch (error) { - errors.push(error); - } - } - } finally { - this.viewer = null; - this.engine = null; - this._registrationsFinalized = false; - this._registrationDispositions = null; - } - if (droppedQaLayers.length) - console.warn( - `[Data] QA layers do not survive a scene switch; register them again: ${droppedQaLayers.join(', ')}`, - ); - if (errors.length) { - for (const error of errors) - console.warn('[Data] scene detach error:', error); - throw new AggregateError(errors, 'Data layers could not detach'); - } - } - - /** Release one retained layer's renderer, keeping a failure for a retry. */ - async _detachEntryRenderer(entry, viewer) { - const pending = entry.pendingSceneDetach; - if (!pending && (!entry.initialized || entry.sceneDetached)) return; - const target = pending ? pending.viewer : viewer; - entry.pendingSceneDetach = { viewer: target }; - await entry.module.detachRenderer(target); - entry.pendingSceneDetach = null; - entry.sceneDetached = true; - } - - /** Bind the next catalog only after the preceding scene released its entries. */ - bindCatalog(viewer, engine, catalog, options = {}) { - if (this._registrationsFinalized) throw new Error('Scene already attached'); - this.viewer = viewer; - this.engine = engine ?? null; - this._catalogSourceAvailability = catalog.getSourceAvailability; - const scene = new AbortController(); - this._sceneController = scene; - // An attachment that is still running when the scene detaches sees this - // signal aborted and must stop touching the scene. - const context = { ...this._moduleContext(scene.signal), ...options }; - const attachments = []; - for (const layer of catalog.layers) { - const entry = this.layers.get(layer.id); - if (!entry) this.register(layer); - else { - if (entry.module !== layer) - throw new Error(`Layer owner changed: ${layer.id}`); - if (entry.initialized) { - attachments.push(this._attachEntryRenderer(entry, viewer, context)); - } - } - } - // Retained entries may appear anywhere in the directory. Preserve both - // catalog order (panel/serialization) and each live entry's identity. - for (const { id } of catalog.layers) { - const entry = this.layers.get(id); - this.layers.delete(id); - this.layers.set(id, entry); - } - this.finalizeRegistrations(catalog.metadata); - const pending = attachments.filter((ready) => ready?.then); - // The scene counts as bound for refreshes and parked enables once its - // attachments settled (or the wait for them timed out). - const bound = () => { - if (this._sceneController !== scene) return; - this._sceneDetached = false; - this._runDeferredRefreshes(); - this._settleSceneWaiters(true); - }; - if (!pending.length) { - bound(); - return undefined; - } - return this._awaitAttachments(pending, 'bind').then((settled) => { - bound(); - const failure = settled?.find(({ status }) => status === 'rejected'); - if (failure) throw failure.reason; - }); - } - - /** - * Wait for attachments to settle, at most attachSettleTimeoutMs. Returns - * their allSettled results, or null when the wait timed out. - */ - async _awaitAttachments(attachments, phase) { - if (!attachments.length) return []; - let timer; - const timedOut = new Promise((resolve) => { - timer = setTimeout(resolve, this._attachSettleTimeoutMs, null); - }); - const settled = await Promise.race([ - Promise.allSettled(attachments), - timedOut, - ]); - clearTimeout(timer); - if (!settled) - console.warn( - `[Data] layer attachments still running after ${this._attachSettleTimeoutMs} ms; scene ${phase} continues without them`, - ); - return settled; - } - - /** - * Wait until a scene is bound. Resolves true then, or false when the - * signal aborts or the entry is destroyed. `refresh` marks an explicit - * refresh, which replaces the entry's deferred periodic one. - */ - _waitForScene(entry, signal = null, { refresh = false } = {}) { - if (!this._sceneDetached) return Promise.resolve(true); - if (signal?.aborted) return Promise.resolve(false); - return new Promise((resolve) => { - const onAbort = () => waiter.settle(false); - const waiter = { - entry, - refresh, - settle: (bound) => { - if (!this._sceneWaiters.delete(waiter)) return; - if (refresh) entry.sceneRefreshWaiters -= 1; - signal?.removeEventListener?.('abort', onAbort); - resolve(bound); - }, - }; - if (refresh) entry.sceneRefreshWaiters += 1; - this._sceneWaiters.add(waiter); - signal?.addEventListener?.('abort', onAbort, { once: true }); - for (const listener of [...this._parkListeners]) listener(entry); - }); - } - - _settleSceneWaiters(bound, entry = null) { - for (const waiter of [...this._sceneWaiters]) - if (!entry || waiter.entry === entry) waiter.settle(bound); - } - - /** The entry's queued lifecycle work settled, or is parked for a scene. */ - _lifecycleIdleOrParked(entry) { - const parked = () => - [...this._sceneWaiters].some( - (waiter) => waiter.entry === entry && !waiter.refresh, - ); - if (parked()) return Promise.resolve(); - return new Promise((resolve) => { - const done = () => { - this._parkListeners.delete(listener); - resolve(); - }; - const listener = (parkedEntry) => { - if (parkedEntry === entry && parked()) done(); - }; - this._parkListeners.add(listener); - entry.toggleChain.catch(() => {}).then(done); - }); - } - - /** - * Attach one retained layer to the bound scene. A release that failed on - * the previous scene is retried first. Asynchronous attachments are tracked - * so detachScene() releases them only after they stopped. - */ - _attachEntryRenderer(entry, viewer, context) { - let ready; - try { - const pending = entry.pendingSceneDetach; - entry.sceneDetached = false; - if (pending) { - ready = Promise.resolve() - .then(() => entry.module.detachRenderer(pending.viewer)) - .then(() => { - entry.pendingSceneDetach = null; - if (context.signal.aborted) return undefined; - return entry.module.attachRenderer(viewer, context); - }); - } else ready = entry.module.attachRenderer(viewer, context); - } catch (error) { - ready = Promise.reject(error); - } - if (!ready?.then) return ready; - const tracked = Promise.resolve(ready); - this._pendingAttachments.add(tracked); - const untrack = () => this._pendingAttachments.delete(tracked); - tracked.then(untrack, untrack); - return tracked; - } - - /** Run the periodic refreshes that came due while no scene was bound. */ - _runDeferredRefreshes() { - for (const [layerId, entry] of this.layers) { - if (!entry.refreshDeferred) continue; - entry.refreshDeferred = false; - // An explicit refresh waiting for this scene runs instead. - if (entry.sceneRefreshWaiters > 0) continue; - void this._runPeriodicUpdate(layerId, entry); - } - } - /** Register a synthetic layer after sealing in an explicitly dev-enabled manager. */ registerForQa(layerModule) { if (!this._allowQaRegistration || !this._registrationsFinalized) { @@ -456,7 +209,6 @@ export class LayerLifecycle { // renderer is released, a release that failed and is retried, and a // periodic refresh that came due while no scene was bound. sceneDetached: false, - pendingSceneDetach: null, refreshDeferred: false, sceneRefreshWaiters: 0, }); diff --git a/src/main.js b/src/main.js index a6c244792..717e79f12 100644 --- a/src/main.js +++ b/src/main.js @@ -4,14 +4,10 @@ import { } from './standalone/application.js'; import { createRendererCache } from './standalone/rendererCache.js'; import { describeError } from './standalone/errors.js'; -import { - createEngineAddress, - createEngineSwitch, - handOffSession, - resumeSession, - waitForFirstFrame, -} from './standalone/engineSwitch.js'; +import { createEngineAddress } from './standalone/engineSwitch.js'; import { ENGINE_STORAGE_KEY, engineOfChoice } from './app/engineChoice.js'; +import { createSceneSwitcher } from './standalone/sceneSwitcher.js'; +import { createLayerSession } from './app/layerSession.js'; import { createSessionDiagnostics } from './app/sessionDiagnostics.js'; import { useDisplayStore } from './ui/stores/display.js'; import { applicationPinia } from './ui/vueRuntime.js'; @@ -31,8 +27,6 @@ const display = useDisplayStore(applicationPinia()); const loading = document.getElementById('loading-screen'); let application; let engineSwitch = null; -let cover = null; -let cockpit = null; // ?parkedMs= shortens how long a parked globe waits before it is released, // so a QA scenario can switch back to a released one. const renderers = createRendererCache({ @@ -41,7 +35,7 @@ const renderers = createRendererCache({ parkedMs: Number(new URLSearchParams(location.search).get('parkedMs')) || undefined, }); -const layerSession = { lifetime: new AbortController() }; +const layerSession = createLayerSession(); const { signal } = layerSession.lifetime; // Which session objects this page holds now (scripts/qa-app-once.mjs). const session = createSessionDiagnostics(() => { @@ -113,115 +107,6 @@ function liveComponents() { // The page's application is created once; a switch detaches and attaches // only its engine-bound parts. -// Hand the session off and release the running engine. Its renderer is -// parked for a later switch back. -async function leave(from) { - const components = liveComponents(); - const attached = Boolean(components); - if (attached) { - cover ??= keepGlobeFrame(components.scene.engine); - cockpit = components.controls.styleManager.getCockpitState(); - handOffSession(layerSession, { - addressLayerIds: - components.controls.styleManager.shareLinkManager?.currentLayerState?.() - ?.enabledLayerIds, - }); - renderers.retain(from); - } - if (attached) await application.detachScene({ handoff: true }); - session.count('detaches'); -} - -// Start an engine on the retained session. Success means the engine runs -// with the session attached and the address's view restored; its first frame -// only decides when the cover goes (settle). -async function enter(kind, { recovery = false } = {}) { - if (!application || application.getState().status !== 'ready') { - // The first engine never started: there is no application to attach to. - return enterNewApplication(kind); - } - layerSession.restoringView = true; - try { - await application.attachScene(kind); - signal.throwIfAborted(); - await resume(application.getComponents().controls.styleManager); - if (recovery) session.count('rollbacks'); - } catch (error) { - // pagehide owns the teardown once the lifetime is aborted. A failed - // candidate is detached; the session stays for the working engine. - if (!signal.aborted) - await application - .detachScene({ handoff: true }) - .catch((cleanupError) => - console.error('A failed globe could not be released:', cleanupError), - ); - throw error; - } -} - -// The session continues on the engine that attached: the view is restored, -// parked work resumes and Cockpit re-enters on the aircraft it had. -async function resume(style) { - await style.initialRestorePromise; - signal.throwIfAborted(); - layerSession.restoringView = false; - session.count('attachments'); - resumeSession(layerSession); - // Only resume the aircraft that actually survived the handoff. A history - // entry may select a different subject, and a stale track may disappear. - const target = style.getAircraftTrackingTarget(); - if ( - cockpit?.active && - target && - target?.id === cockpit.subject?.id && - target?.layerId === cockpit.subject?.layerId && - style.controlCockpit('enter').ok - ) { - style.cockpitView.setVisionMode(cockpit.visionMode); - } -} - -// A new application on the retained session, for a page whose first engine -// never started. -async function enterNewApplication(kind) { - const app = createApplication(kind, true); - try { - const { controls } = await app.start(); - signal.throwIfAborted(); - await resume(controls.styleManager); - } catch (error) { - // pagehide owns the teardown once the lifetime is aborted. - if (!signal.aborted) - await app - .destroy() - .catch((cleanupError) => - console.error('A failed globe could not be released:', cleanupError), - ); - throw error; - } -} - -async function settle() { - const components = liveComponents(); - try { - if (cover && components) await waitForFirstFrame(components.scene.engine); - } finally { - cover?.remove(); - cover = null; - } - // A page left without an engine keeps the last handoff for the next try. - if (!components) return; - layerSession.handoffLayers = null; - cockpit = null; - withdrawEngineRetry(); - // An engine runs again after a failed recovery showed its error: the - // application, built once, does not start its loading chrome again. - loading.classList.add('hidden'); - // The running engine owns the address again, including an entry visited - // before its share link listened or while it was held. - components.controls.styleManager.shareLinkManager?.resumeNavigation?.(); -} - // Voice calls made while the engines switch wait for the next engine // (src/voice/sceneRunner.js); a settled switch lets them look again. layerSession.switching = () => engineSwitch?.state().busy === true; @@ -296,13 +181,20 @@ createApplication(initialKind, false) .start() .then(() => { session.count('attachments'); - engineSwitch = createEngineSwitch({ + engineSwitch = createSceneSwitcher({ kind: initialKind, requested: pageEngineKind, signal, - leave, - enter, - settle, + getApplication: () => application, + startApplication: (kind) => createApplication(kind, true), + layerSession, + renderers, + diagnostics: session, + keepFrame: keepGlobeFrame, + onSettled() { + withdrawEngineRetry(); + loading.classList.add('hidden'); + }, address: createEngineAddress({ location, history, diff --git a/src/scenes/director.js b/src/scenes/director.js index 189b111e9..b89a23d2f 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -73,33 +73,31 @@ const STORAGE_CHECKPOINT_KEY = 'godsEyeView.sceneProject.checkpoint.v1'; * Orchestrates deterministic cinematic scene playback. * * The director owns a mutable project (persisted in localStorage) containing - * scenes and shots. It drives camera flights via Cesium, applies visual/style - * state through the styleManager, toggles data layers via the dataManager, and + * scenes and shots. It drives camera flights through the globe engine, applies + * visual state through the styleManager, toggles data layers via the dataManager, and * records timestamped telemetry events during each run for later export. */ export class SceneDirector { /** - * @param {Cesium.Viewer} viewer - The Cesium viewer instance + * @param {{ engine: import('../globe/contract.js').GlobeEngine, viewer?: object }} scene * @param {Object} styleManager - Controls visual state (bloom, sharpen, HUD, detection, style presets) * @param {Object} dataManager - Manages data layer enable/disable and per-layer params - * @param {Object} [options] `engine`: the globe engine whose camera the - * scene flights drive; the viewer still serves tracking, interactions and - * data packs. + * @param {Object} [options] Scene packs, data sources and map availability. */ constructor( - viewer, + scene, styleManager, dataManager, { - engine = null, isMapStackAvailable = () => false, scenePacks = createDefaultScenePacks(), dataPacks = {}, } = {}, ) { + const { engine } = scene; + this._scene = scene; this._destroyed = false; this._pendingWork = new Set(); - this.viewer = viewer; this.engine = engine; this.styleManager = styleManager; this.dataManager = dataManager; @@ -108,7 +106,7 @@ export class SceneDirector { this._bundleAssets = createBundleAssets(); this._dataPacks = createSceneDataPacks(engine, { ...dataPacks, - container: engine?.host?.container ?? viewer?.container, + container: engine?.host?.container, sources: { ...dataPacks.sources, [BUNDLE_SOURCE]: this._bundleAssets.source, @@ -119,7 +117,9 @@ export class SceneDirector { this._interactions = createSceneInteractions({ engine, available: () => - !this._destroyed && !this._running && !this.viewer?.trackedEntity, + !this._destroyed && + !this._running && + !this._scene?.viewer?.trackedEntity, execute: (action, signal) => this._trackWork(this._executeInteraction(action, signal)), }); @@ -235,11 +235,11 @@ export class SceneDirector { } _bindCameraInput() { - const { viewer, engine, styleManager } = this; + const { engine, styleManager } = this; this._cameraHandoffUnsubscribe = styleManager.subscribeCameraHandoff?.( this._yieldCamera, ); - const canvas = engine?.host.canvas ?? viewer?.scene?.canvas; + const canvas = engine?.host.canvas; for (const event of ['pointerdown', 'wheel']) canvas?.addEventListener?.(event, this._yieldCamera, { passive: true }); this._removeCameraInput = () => { @@ -258,11 +258,13 @@ export class SceneDirector { this._interactions.setScene(null); this._dataPacks.setScene(null); this._flight?.suspend(); - this.engine = this.viewer = null; + this._scene = null; + this.engine = null; } - attachScene(viewer, engine, styleManager, isAvailable) { - this.viewer = viewer; + attachScene(scene, styleManager, isAvailable) { + const { engine } = scene; + this._scene = scene; this.engine = engine; this.styleManager = styleManager; this._isMapStackAvailable = isAvailable; @@ -327,7 +329,7 @@ export class SceneDirector { return promise; } - /** Stop playback and pending loads before releasing their viewer. */ + /** Stop playback and pending loads before releasing their engine. */ destroy() { if (this._destroyPromise) return this._destroyPromise; this._destroyed = true; diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index c266b9fbc..34687c4da 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -20,6 +20,8 @@ import { LayerLifecycle } from '../data/lifecycle.js'; import { SceneDirector } from './director.js'; import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js'; import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js'; +import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs'; +import { useSceneInteractionsStore } from '../ui/stores/sceneInteractions.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; const NEPAL_ORIGINAL_SHOT_TITLES = [ @@ -257,7 +259,7 @@ test('same-shot seeking restores a layer whose superseded LOAD transition rolls update() {}, }); } - const realDirector = new SceneDirector(viewer, styleManager, lifecycle, { engine: engineFor(viewer) }); + const realDirector = new SceneDirector({ engine: engineFor(viewer), viewer }, styleManager, lifecycle); try { await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); await dataManager.setEnabled('flights', false); @@ -371,7 +373,7 @@ test('same-shot seeking aborts in-flight admission when superseded by an early-r disable() {}, update() {}, }); - const director = new SceneDirector(viewer, styleManager, lifecycle, { engine: engineFor(viewer) }); + const director = new SceneDirector({ engine: engineFor(viewer), viewer }, styleManager, lifecycle); try { await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); assert.equal(await director.seekScene('scene-1', 0.1), true); @@ -449,7 +451,7 @@ test('a rejected loadShot does not cancel an in-flight LOAD', async () => { disable() {}, update() {}, }); - const director = new SceneDirector(viewer, styleManager, lifecycle, { engine: engineFor(viewer) }); + const director = new SceneDirector({ engine: engineFor(viewer), viewer }, styleManager, lifecycle); try { const load = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); for ( @@ -1113,8 +1115,7 @@ function makeDirector(options = {}) { const viewer = fakeViewer(); const styleManager = fakeStyleManager(options.style); const dataManager = fakeDataManager(options.data); - const director = new SceneDirector(viewer, styleManager, dataManager, { - engine: engineFor(viewer), + const director = new SceneDirector({ engine: engineFor(viewer), viewer }, styleManager, dataManager, { isMapStackAvailable: options.isMapStackAvailable, }); // Telemetry is only accumulated during a run; observable-failure assertions @@ -1790,7 +1791,7 @@ test('destroy drains cancelled LOAD work before its viewer can be discarded', as let finishVisual; const dataManager = fakeDataManager(); const viewer = { camera: { cancelFlight() {} } }; - const director = new SceneDirector(viewer, { + const director = new SceneDirector({ engine: engineFor(viewer), viewer }, { applyVisualState: () => new Promise((resolve) => { finishVisual = resolve; }), }, dataManager); const loading = director.loadShot('scene-1', 'shot-a'); @@ -1981,7 +1982,7 @@ test('scene placement, flights and stop run on a globe engine without a viewer', const restore = installSceneRuntime(); try { const engine = createFakeEngine(); - const director = new SceneDirector(null, fakeStyleManager(), fakeDataManager(), { engine }); + const director = new SceneDirector({ engine }, fakeStyleManager(), fakeDataManager()); const pose = { lat: 27.95, lon: 85.95, alt: 4200, heading: 40, pitch: -25, roll: 0 }; assert.equal(director._setCameraView(pose), true); const placed = engine.camera.pose().cartographic; @@ -2087,8 +2088,7 @@ test('a failed scene-pack rebind releases the partial renderer and a stopped sho assert.throws( () => director.attachScene( - {}, - { host: { container: {} } }, + { engine: { host: { container: {} } } }, {}, () => true, ), @@ -2105,3 +2105,37 @@ test('a failed scene-pack rebind releases the partial renderer and a stopped sho director.resumeScene(); assert.equal(await waiting, true); }); + + +for (const createHarness of GROUND_HARNESSES) { + test(`Director retains the existing tracking interaction policy on ${createHarness.name}`, async () => { + const restore = installSceneRuntime(); + const harness = createHarness(); + const { engine } = harness; + const director = new SceneDirector({ engine, viewer: harness.viewer }, fakeStyleManager(), fakeDataManager()); + const store = useSceneInteractionsStore(applicationPinia()); + const target = { packId: 'test', featureId: 'point' }; + const shot = { interactions: [{ id: 'card', label: 'Read', target, action: { type: 'card', text: 'Details' } }] }; + try { + director._interactions.activate(shot, new Map([[JSON.stringify(['test', 'point']), 'point']])); + 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'); + follow.stop(); + store.controls.dispatch('card', store.panel.owner); + await settle(); + assert.equal(store.panel.card.text, 'Details'); + director.suspendScene(); + assert.equal(director.engine, null); + director.attachScene({ engine, viewer: harness.viewer }, director.styleManager, () => true); + director.resumeScene(); + assert.equal(director.engine, engine); + } finally { + await director.destroy(); + harness.destroy(); + restore(); + } + }); +} diff --git a/src/standalone/application.js b/src/standalone/application.js index 7065f6aae..360c990b3 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -1,3 +1,4 @@ +import { retainsLayerSession } from '../app/layerSession.js'; import { createStandaloneCatalog } from './catalog.js'; import { createStandalonePlaceSearch } from './placeSearch.js'; import { CITY_POIS } from '../cityPois.js'; @@ -94,7 +95,9 @@ export function createStandaloneApplication({ async function openScene(kind) { const controller = new AbortController(); const releases = []; - const release = async () => { + let releasing; + const release = () => (releasing ??= releaseResources()); + const releaseResources = async () => { // A reason made here would keep this call's stack, and with it the // released scene, alive in every closure that still holds `signal`. controller.abort(SCENE_RELEASED); @@ -156,6 +159,7 @@ export function createStandaloneApplication({ }); return Object.freeze({ ...scene, + signal, kind, catalog: sceneCatalog, release, @@ -254,10 +258,7 @@ export function createStandaloneApplication({ // the globe's renderer is parked or released. context.defer(async () => { await sceneHost.detach({ - handoff: Boolean( - layerSession?.handoffLayers && - !layerSession.lifetime.signal.aborted, - ), + handoff: retainsLayerSession(layerSession), }); const scene = current; current = null; @@ -332,14 +333,30 @@ export function createStandaloneApplication({ * detaches what attached and releases the candidate; the session stays as * it was, ready for the working engine to attach again. */ - async function attachScene(kind) { + async function attachScene(kind, { resume } = {}) { if (application.getState().status !== 'ready') throw new Error('The application is not running'); if (current) throw new Error('A globe is attached; detach it first'); const next = await openScene(kind); try { await sceneHost.attach(next); + current = next; + await resume?.(); + lifetime.throwIfAborted(); } catch (error) { + // attach() releases partial parts itself; a failed resume leaves a + // complete attachment for this transaction to detach. + if (current === next) { + current = null; + await sceneHost + .detach({ handoff: !lifetime.aborted }) + .catch((cleanupError) => + console.error( + 'A failed globe could not be released:', + cleanupError, + ), + ); + } await next .release() .catch((cleanupError) => @@ -347,7 +364,6 @@ export function createStandaloneApplication({ ); throw error; } - current = next; return sceneView; } diff --git a/src/standalone/applicationSwitch.test.mjs b/src/standalone/applicationSwitch.test.mjs index ea6b96163..917c71efa 100644 --- a/src/standalone/applicationSwitch.test.mjs +++ b/src/standalone/applicationSwitch.test.mjs @@ -5,6 +5,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import vm from 'node:vm'; +import { retainsLayerSession } from '../app/layerSession.js'; import { createSceneHost } from '../app/sceneHost.js'; import { attachScenePart } from '../app/sceneParts.js'; @@ -50,6 +51,7 @@ function standalone({ failOpen = null } = {}) { return { viewer: null, engine, operations: { kind: 'operations' } }; }, createSceneHost, + retainsLayerSession, attachScenePart, AbortController, AbortSignal, @@ -111,3 +113,52 @@ test('a failed candidate releases what it attached and the working engine attach ); assert.equal(opened.length, 3); }); + +test('a failed resume detaches and releases its candidate once before recovery', async () => { + const { app, opened, released } = standalone(); + const scene = await app.createScene({ signal: new AbortController().signal, defer() {} }); + const detached = []; + await scene.sceneHost.register({ + name: 'part', + attachScene(next) { this.kind = next.kind; }, + detachScene(options) { detached.push([this.kind, options.handoff]); }, + }); + await app.detachScene({ handoff: true }); + await assert.rejects(app.attachScene('navara', { + resume() { + assert.equal(scene.engine, opened[1], 'resume sees the attached candidate'); + throw new Error('restore failed'); + }, + }), /restore failed/); + assert.equal(scene.engine, null); + assert.equal(scene.sceneHost.state(), 'detached'); + assert.deepEqual(detached, [['cesium', true], ['navara', true]]); + assert.deepEqual(released, opened); + await app.attachScene('cesium'); + assert.equal(scene.engine, opened[2]); + assert.equal(released.length, 2); +}); + +test('page teardown during resume releases the candidate only once', async () => { + const { app, opened, released } = standalone(); + const lifetime = new AbortController(); + const scene = await app.createScene({ signal: lifetime.signal, defer() {} }); + await app.detachScene({ handoff: true }); + let finishResume; + let enteredResume; + const entered = new Promise((resolve) => { enteredResume = resolve; }); + const pending = app.attachScene('navara', { + resume: () => { + enteredResume(); + return new Promise((resolve) => { finishResume = resolve; }); + }, + }); + const rejected = assert.rejects(pending, { name: 'AbortError' }); + await entered; + lifetime.abort(); + const leaving = app.detachScene({ handoff: false }); + finishResume(); + await Promise.all([leaving, rejected]); + assert.equal(scene.engine, null); + assert.deepEqual(released, opened); +}); diff --git a/src/standalone/sceneSwitcher.js b/src/standalone/sceneSwitcher.js new file mode 100644 index 000000000..a07e3cbd7 --- /dev/null +++ b/src/standalone/sceneSwitcher.js @@ -0,0 +1,118 @@ +import { + createEngineSwitch, + handOffSession, + resumeSession, + waitForFirstFrame, +} from './engineSwitch.js'; + +/** + * One scene-switch transaction: retain the session, detach and park the + * outgoing globe, attach and resume the candidate, then uncover its frame. + * The request coordinator owns ordering and the single rollback. The + * application owns candidate cleanup, including a failure during resume. + * + * @param {object} options + * @param {import('../app/layerSession.js').LayerSession} options.layerSession + */ +export function createSceneSwitcher({ + getApplication, + startApplication, + layerSession, + renderers, + diagnostics, + keepFrame, + onSettled, + ...options +}) { + const { signal } = layerSession.lifetime; + let cover = null; + let cockpit = null; + const count = (name) => diagnostics?.count(name); + const liveComponents = () => { + const app = getApplication(); + if (app?.getState().status !== 'ready') return null; + const components = app.getComponents(); + return components.scene?.engine ? components : null; + }; + + async function leave(from) { + const components = liveComponents(); + if (components) { + cover ??= keepFrame?.(components.scene.engine); + const style = components.controls.styleManager; + cockpit = style.getCockpitState(); + handOffSession(layerSession, { + addressLayerIds: + style.shareLinkManager?.currentLayerState?.()?.enabledLayerIds, + }); + renderers.retain(from); + await getApplication().detachScene({ handoff: true }); + } + count('detaches'); + } + + async function resume(style) { + signal.throwIfAborted(); + await style.initialRestorePromise; + signal.throwIfAborted(); + layerSession.restoringView = false; + count('attachments'); + resumeSession(layerSession); + // A visited entry or a stale track may replace the outgoing subject. + const target = style.getAircraftTrackingTarget(); + if ( + cockpit?.active && + target && + target.id === cockpit.subject?.id && + target.layerId === cockpit.subject?.layerId && + style.controlCockpit('enter').ok + ) { + style.cockpitView.setVisionMode(cockpit.visionMode); + } + } + + async function enter(kind, { recovery = false } = {}) { + layerSession.restoringView = true; + let app = getApplication(); + if (!app || app.getState().status !== 'ready') { + app = startApplication(kind); + try { + const { controls } = await app.start(); + await resume(controls.styleManager); + } catch (error) { + if (!signal.aborted) + await app + .destroy() + .catch((cleanupError) => + console.error( + 'A failed globe could not be released:', + cleanupError, + ), + ); + throw error; + } + } else { + await app.attachScene(kind, { + resume: () => resume(app.getComponents().controls.styleManager), + }); + } + if (recovery) count('rollbacks'); + } + + async function settle() { + const components = liveComponents(); + try { + if (cover && components) await waitForFirstFrame(components.scene.engine); + } finally { + cover?.remove(); + cover = null; + } + if (!components) return; + layerSession.handoffLayers = null; + cockpit = null; + onSettled?.(); + components.controls.styleManager.shareLinkManager?.resumeNavigation?.(); + } + + return createEngineSwitch({ ...options, signal, leave, enter, settle }); +} diff --git a/src/standalone/sceneSwitcher.test.mjs b/src/standalone/sceneSwitcher.test.mjs new file mode 100644 index 000000000..faf335f67 --- /dev/null +++ b/src/standalone/sceneSwitcher.test.mjs @@ -0,0 +1,137 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createSceneSwitcher } from './sceneSwitcher.js'; +import { createLayerSession } from '../app/layerSession.js'; + +function fixture({ failResume = false, changeTarget = false } = {}) { + const session = createLayerSession(); + const log = []; + let current = 'first'; + let address = 'first'; + const target = { id: 'track', layerId: 'flights' }; + const style = { + initialRestorePromise: Promise.resolve(), + getCockpitState: () => ({ + active: true, + subject: target, + visionMode: 'ir', + }), + getAircraftTrackingTarget: () => + changeTarget ? { ...target, id: 'other' } : target, + controlCockpit: () => { + log.push('cockpit'); + return { ok: true }; + }, + cockpitView: { setVisionMode: (mode) => log.push(mode) }, + shareLinkManager: { + currentLayerState: () => ({ enabledLayerIds: ['flights'] }), + resumeNavigation: () => log.push('navigation'), + }, + }; + const entry = { + module: { + attachRenderer() {}, + resumeViewport() { + log.push('viewport'); + }, + }, + }; + session.manager = { + layers: new Map([['flights', entry]]), + isEffectivelyEnabled: () => true, + }; + session.director = { + suspendScene: () => log.push('suspend-director'), + resumeScene: () => { + log.push('resume-director'); + if (failResume && current === 'navara') throw new Error('resume failed'); + }, + }; + const app = { + getState: () => ({ status: 'ready' }), + getComponents: () => ({ + scene: { engine: current ? { kind: current } : null }, + controls: { styleManager: style }, + }), + async detachScene({ handoff }) { + log.push(['detach', current, handoff]); + current = null; + }, + async attachScene(kind, { resume }) { + current = kind; + log.push(['attach', kind]); + try { + await resume(); + } catch (error) { + log.push(['release', kind]); + current = null; + throw error; + } + }, + }; + const switcher = createSceneSwitcher({ + kind: 'first', + requested: () => address, + layerSession: session, + getApplication: () => app, + renderers: { retain: (kind) => log.push(['park', kind]) }, + address: { + select: () => 'old-entry', + commit: (token, kind) => { + address = kind; + log.push(['commit', token, kind]); + }, + restore: (kind) => { + address = kind; + }, + }, + onSettled: () => log.push('settled'), + onFailure: (error, { recovered }) => + log.push(['failure', error.message, recovered]), + }); + return { switcher, session, log, current: () => current }; +} + +test('a scene switch parks once, resumes the session and cockpit, then returns navigation', async () => { + const { switcher, session, log, current } = fixture(); + await switcher.select('navara'); + assert.equal(current(), 'navara'); + assert.equal(session.handoffLayers, null); + assert.equal(session.restoringView, false); + assert.deepEqual(log, [ + 'suspend-director', + ['park', 'first'], + ['detach', 'first', true], + ['attach', 'navara'], + 'viewport', + 'resume-director', + 'cockpit', + 'ir', + ['commit', 'old-entry', 'navara'], + 'settled', + 'navigation', + ]); +}); + +test('resume failure has one candidate release and one rollback on the retained session', async () => { + const { switcher, session, log, current } = fixture({ failResume: true }); + await switcher.select('navara'); + assert.equal(current(), 'first'); + assert.deepEqual(log.filter(Array.isArray), [ + ['park', 'first'], + ['detach', 'first', true], + ['attach', 'navara'], + ['release', 'navara'], + ['attach', 'first'], + ['failure', 'resume failed', true], + ]); + assert.equal(session.handoffLayers, null); + assert.equal(switcher.state().busy, false); +}); + +test('a changed tracking target does not resume the outgoing cockpit', async () => { + const { switcher, log } = fixture({ changeTarget: true }); + await switcher.select('navara'); + assert.equal(log.includes('cockpit'), false); + assert.equal(log.includes('ir'), false); +}); diff --git a/src/testSupport/readShellSource.mjs b/src/testSupport/readShellSource.mjs index 957494b4f..9daf91b22 100644 --- a/src/testSupport/readShellSource.mjs +++ b/src/testSupport/readShellSource.mjs @@ -1,3 +1,4 @@ +import { ShellScene } from '../ui/shellScene.js'; import { ShellFacade } from '../ui/shellFacade.js'; import { AircraftDisplay } from '../ui/aircraftDisplay.js'; import { LayerBindings } from '../ui/layerBindings.js'; @@ -24,6 +25,7 @@ export function readShellSource() { 'layerBindings', 'displayBindings', 'shellFacade', + 'shellScene', 'applicationShell', ] .map((name) => @@ -45,6 +47,7 @@ export function shellMethod(name) { LayerBindings, DisplayBindings, StyleManager, + ShellScene, ShellFacade, ]) { const method = Object.getOwnPropertyDescriptor( diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index ee847950b..b3c97c6b9 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1,23 +1,15 @@ +import { retainsLayerSession } from '../app/layerSession.js'; import { nextTick } from 'vue'; -import { resolveImageryHost } from '../layers/weather/imageryHost.js'; -import { ShellFacade } from './shellFacade.js'; +import { ShellScene } from './shellScene.js'; import { AircraftDisplay } from './aircraftDisplay.js'; -import { LayerBindings } from './layerBindings.js'; import { PanelChrome } from './panelChrome.js'; import { initMobileShell } from './mobileShell.js'; -import { VisualSettings } from './visualSettings.js'; import { NavigationController } from './navigationController.js'; -import { ShareRestoration } from './shareRestoration.js'; import { DisplayBindings } from './displayBindings.js'; -import { - applyPendingVisualChoices, - pendingVisualSettings, -} from './pendingVisualSettings.js'; import { createStateChannel } from '../app/stateChannel.js'; import { UiLifetime } from './uiLifetime.js'; import { RecordingControls } from './recordingControls.js'; import { readShellElements } from './shellElements.js'; -import { CockpitCoordinator } from './cockpitCoordinator.js'; import { ContextControls } from './context.js'; import { CctvControls } from './cctv.js'; import { StreetLevelControls } from './streetLevelControls.js'; @@ -40,10 +32,7 @@ import { cesiumHeading, cesiumRoll } from '../cameraAngles.js'; import { aircraftTrackingTarget } from '../cockpitTracking.js'; import { ShellFeedback } from './shellFeedback.js'; -import { - applyShareFollowOffset, - readShareFollowSubject, -} from './shareFollow.js'; +import { readShareFollowSubject } from './shareFollow.js'; import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js'; @@ -59,7 +48,6 @@ import { PanelNavigation } from './panelNavigation.js'; import { applicationPinia, createUiRuntime } from './vueRuntime.js'; import { useContextStore } from './stores/context.js'; import { useMissionReplayStore } from './stores/missionReplay.js'; -import { useCelestialRingStore } from './stores/celestialRing.js'; import { useWorldOverlayActionsStore } from './stores/worldOverlayActions.js'; import { useAwarenessDirectionStore } from './stores/awarenessDirection.js'; import { useHudStore } from './stores/hud.js'; @@ -89,7 +77,7 @@ import { useRecordingStore } from './stores/recording.js'; * - Intel HUD lifecycle and variant switching. */ -export class StyleManager extends ShellFacade { +export class StyleManager extends ShellScene { /** * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. * @param {object} [options] `engine`: the page's globe engine (the world @@ -438,445 +426,6 @@ export class StyleManager extends ShellFacade { this._layerBindings.observeCamera(); } - // ── Scene attachment ────────────────────────────────────────────────── - // The shell lives for the page; an engine switch only rebuilds the parts - // bound to one globe (share restoration, visual effects, layer bindings, - // Cockpit, the celestial ring, the overlay host, detection, the map-source - // and camera-orientation controls) and points the kept ones (navigation, - // location search, HUD, share link, display bindings) at the next globe. - - /** Share restoration, visual settings and layer bindings of this globe. */ - _buildSceneControls() { - const { viewer, engine, mapStackController } = this._scene; - const services = this.services; - const layerSession = this._layerSession; - this._shareRestoration = new ShareRestoration({ - engine, - canvas: engine?.host.canvas ?? viewer?.canvas, - handoffLayers: layerSession?.handoffLayers, - navigation: this._navigation, - syncShareState: () => this._syncShareState(), - syncModels3d: (state) => this._syncModels3dFromLayerState(state), - showStatus: (message, options) => - this._showGlobalStatusNotice(message, options), - feedback: this._feedback, - updateFeedback: () => this._updateGlobalLoadingFeedback(), - applyFollowOffset: (handle, offset) => - applyShareFollowOffset(engine, handle, offset), - }); - - this._visualSettings = new VisualSettings({ - viewer, - engine, - mapStackController, - services: { - setScopeTerminusOverride: services.setScopeTerminusOverride, - clampScopeTerminusPct: services.clampScopeTerminusPct, - getDetectionMode: services.getDetectionMode, - getDetectionTuning: services.getDetectionTuning, - getKeyholeFadeTuning: services.getKeyholeFadeTuning, - getScopeMaskFeather: services.getScopeMaskFeather, - getScopeTerminusOverride: services.getScopeTerminusOverride, - governorRequestRender: services.governorRequestRender, - holdContinuousRender: services.holdContinuousRender, - isCelestialRingStyleSupported: services.isCelestialRingStyleSupported, - isScopeMaskEnabled: services.isScopeMaskEnabled, - readDetectionDiagnostics: services.readDetectionDiagnostics, - releaseContinuousRender: services.releaseContinuousRender, - setDetectionModeByLabel: services.setDetectionModeByLabel, - setDetectionStyle: services.setDetectionStyle, - setDetectionTuning: services.setDetectionTuning, - setKeyholeFadeTuning: services.setKeyholeFadeTuning, - setScopeMaskEnabled: services.setScopeMaskEnabled, - setScopeMaskFeather: services.setScopeMaskFeather, - }, - elements: { - _ppToggles: this._ppToggles, - _sliderPanel: this._sliderPanel, - }, - operations: { - _restorePanelState: (...args) => this._restorePanelState(...args), - _layoutRightPanels: (...args) => this._layoutRightPanels(...args), - _scheduleAdaptivePanelLayout: (...args) => - this._scheduleAdaptivePanelLayout(...args), - _scheduleRightPanelLayout: (...args) => - this._scheduleRightPanelLayout(...args), - _setCockpitDisclosure: (...args) => this._setCockpitDisclosure(...args), - _cockpitDisclosureOpen: (kind) => this._cockpitDisclosureOpen(kind), - _setMapStack: (...args) => this._setMapStack(...args), - _presentPanel: (...args) => this._presentPanel(...args), - _syncShareState: (...args) => this._syncShareState(...args), - setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), - }, - readHud: () => this.hud, - readCockpit: () => this.cockpitView, - readDataManager: () => this._dataManager, - readShareLinks: () => this.shareLinkManager, - readCelestialRing: () => this.celestialRing, - readContextMode: () => this._contextMode, - readContextChanging: () => this._contextModeChanging, - readDisplayPortalActive: () => this._cockpitDisplayPortalActive, - }); - - this._layerBindings = new LayerBindings({ - viewer, - engine, - services: { - imageryHost: () => - resolveImageryHost({ - viewer, - tileset: mapStackController?.getImageryHostTileset?.(), - }), - cachedGroundFloor: services.cachedGroundFloor, - warmGroundFloor: services.warmGroundFloor, - cctvLayer: services.cctvLayer, - }, - readControls: () => ({ - hud: this.hud, - _contextControls: this._contextControls, - _cctvControls: this._cctvControls, - _radioControls: this._radioControls, - _streetLevelControls: this._streetLevelControls, - }), - operations: { - _updateTrafficSyncChip: (...args) => - this._updateTrafficSyncChip(...args), - _updateGlobalLoadingFeedback: (...args) => - this._updateGlobalLoadingFeedback(...args), - _syncContextModeButtons: (...args) => - this._syncContextModeButtons(...args), - _stampNavigation: (...args) => this._stampNavigation(...args), - _runExplicitCctvFocus: (...args) => this._runExplicitCctvFocus(...args), - _runExplicitWorldFocus: (...args) => - this._runExplicitWorldFocus(...args), - runImmediateNavigation: (...args) => - this.runImmediateNavigation(...args), - _showToast: (...args) => this._showToast(...args), - }, - feedback: this._feedback, - shareRestoration: this._shareRestoration, - }); - } - - /** Cockpit and the celestial ring of this globe. */ - _buildSceneCockpit() { - const { viewer, engine } = this._scene; - const services = this.services; - const { CelestialRing } = services; - this._cockpitCoordinator = new CockpitCoordinator({ - viewer, - engine, - services: { - flightsLayer: services.flightsLayer, - militaryFlightsLayer: services.militaryFlightsLayer, - isTr3b: services.isTr3b, - toggleTr3b: services.toggleTr3b, - militaryAwarenessLayer: services.militaryAwarenessLayer, - cachedGroundFloor: services.cachedGroundFloor, - cachedMeshFloor: services.cachedMeshFloor, - GROUND_FLOOR_LIFT_M: services.GROUND_FLOOR_LIFT_M, - meshFloorPreferred: services.meshFloorPreferred, - warmGroundFloor: services.warmGroundFloor, - sampleMeshFloorCells: services.sampleMeshFloorCells, - holdContinuousRender: services.holdContinuousRender, - releaseContinuousRender: services.releaseContinuousRender, - fetchRegionalBrief: services.fetchRegionalBrief, - regionalDistanceM: services.regionalDistanceM, - weatherCodeLabel: services.weatherCodeLabel, - }, - elements: { - _ppToggles: this._ppToggles, - _cockpitDisplayPanel: this._cockpitDisplayPanel, - _hudBtn: this._hudBtn, - _detectionBtn: this._detectionBtn, - _sliderPanel: this._sliderPanel, - _models3dBtn: this._models3dBtn, - }, - operations: { - publishCockpit: { - presence: (view) => - useCockpitStore(applicationPinia()).publishPresence(view), - brief: (view) => - useCockpitStore(applicationPinia()).publishBrief(view), - context: (view) => - useCockpitStore(applicationPinia()).publishContext(view), - instruments: (view) => - useCockpitStore(applicationPinia()).publishInstruments(view), - route: (view) => - useCockpitStore(applicationPinia()).publishRoute(view), - vision: (view) => - useCockpitStore(applicationPinia()).publishVision(view), - signals: (rows) => - useCockpitStore(applicationPinia()).publishSignals(rows), - utilityLayout: (layout) => - useCockpitStore(applicationPinia()).publishUtilityLayout(layout), - }, - publishCockpitPortal: (slots) => - useDisplayStore(applicationPinia()).publishCockpitPortal(slots), - _layoutRightPanels: (...args) => this._layoutRightPanels(...args), - _setCockpitVision: (...args) => this._setCockpitVision(...args), - _stampNavigation: (...args) => this._stampNavigation(...args), - getAircraftTrackingTarget: (...args) => - this.getAircraftTrackingTarget(...args), - _scheduleRightPanelLayout: (...args) => - this._scheduleRightPanelLayout(...args), - }, - readDataManager: () => this._dataManager, - readContext: () => this.getContextModeState(), - readActiveStyle: () => this.activeStyle, - readUtilityDisclosureOpen: () => - this._cockpitDisclosureOpen('display') || - this._cockpitDisclosureOpen('radio'), - enterPanels: () => this._panelChrome.enterCockpit(), - exitPanels: () => this._panelChrome.exitCockpit(), - }); - - // Full-globe sun/moon ring on either engine, below the HUD/readout. - this.celestialRing = new CelestialRing(viewer ?? engine, { - engine, - enabled: false, - store: useCelestialRingStore(applicationPinia()), - onAutoDisable: () => - this.setCelestialRingEnabled(false, { - syncShare: !!this.shareLinkManager, - focus: false, - }), - }); - } - - /** The world-overlay host, detection and post-process stages of this globe. */ - _initSceneOverlays() { - const { engine } = this._scene; - const { - initWorldOverlay, - initDetection, - initTrackedReadout, - setDetectionStyle, - trafficLayer, - flightsLayer, - militaryFlightsLayer, - localAdsbLayer, - satellitesLayer, - cctvLayer, - bikeshareLayer, - transitLayer, - aisLiveVesselsLayer, - } = this.services; - // The shared world-overlay host must own its one postRender lane before - // detection and tracked-readout initialize. It stays transparent until a - // production source explicitly registers entries. - initWorldOverlay(engine); - // Initialize detection overlay BEFORE style stages so the composite - // stage is first in the post-process pipeline. Both run on the engine, - // Navara's too. - { - initDetection( - engine, - [ - trafficLayer, - flightsLayer, - militaryFlightsLayer, - localAdsbLayer, - satellitesLayer, - cctvLayer, - bikeshareLayer, - transitLayer, - aisLiveVesselsLayer, - ].filter(Boolean), - (modeLabel) => { - this._updateDetectionButton(modeLabel); - }, - ); - initTrackedReadout(engine); - } - setDetectionStyle(this.activeStyle); - this._setDetectionDensity(); - - this._initStages(); - this._initBloomSharpen(); - } - - /** - * Attach the shell to another globe (an engine switch): `{ viewer, engine, - * mapStackController, services }`, where `services` are that catalog's - * layer roles. The shell, its stores, open panels, typed text and focus - * stay; the parts bound to a globe are built for this one and the - * address's view is restored on it. - */ - attachScene(options = {}) { - return this._queueSceneTransition(() => this._attachScene(options)); - } - - _queueSceneTransition(work) { - const run = (this._sceneTransition ?? Promise.resolve()).then(work, work); - this._sceneTransition = run.catch(() => {}); - return run; - } - - async _attachScene({ - viewer = null, - engine = null, - mapStackController = null, - services = {}, - engineSwitch = true, - } = {}) { - if (this._disposed) return; - if (this._scene.engine === engine && this._scene.viewer === viewer) return; - if (this._scene.engine) - throw new Error('The shell is attached to a globe already'); - // An attachment that fails part-way releases what it built: the shell - // stays detached, ready for the working engine. - try { - const previousServices = this.services; - this.services = { ...this.services, ...services }; - this._scene = { viewer, engine, mapStackController }; - const { - flightsLayer, - militaryFlightsLayer, - satellitesLayer, - aisLiveVesselsLayer, - militaryAwarenessLayer, - rocketLaunchesLayer, - } = this.services; - this._navigation.tracking = { - flightsLayer, - militaryFlightsLayer, - satellitesLayer, - aisLiveVesselsLayer, - militaryAwarenessLayer, - rocketLaunchesLayer, - }; - this._navigation.setScene({ viewer, engine }); - this._locationNavigation.attachScene({ viewer, engine }); - this.hud.attachEngine(engine, { viewer }); - this.shareLinkManager.attachEngine(engine); - this._displayBindings.attachViewer(viewer); - this._buildSceneControls(); - this._buildSceneCockpit(); - this._shareRestoration.attachLinks(this.shareLinkManager); - this._initSceneOverlays(); - this._initMapStackControl(); - this._refreshLayerControls(previousServices); - this._initCameraOrientationControls(); - this._initHudScene(); - this._initCockpitDisplayPortal(); - this._applyGlobalPostDefaults(); - this._startAnimationLoop(); - this._publishActiveStyle(); - this._shareRestoration.start({ - engineSwitch, - retainScene: Boolean(this._layerSession?.handoffLayers), - }); - this._layerBindings.observeCamera(); - this._layoutRightPanels(); - this._syncCctvPanelViewport(); - // Choices made between the engines apply once this engine restored - // the address's view: the person's choice wins over the link. - const pending = this._pendingVisual?.queue; - this._pendingVisual = null; - if (pending?.length) { - const settings = this._visualSettings; - void this.initialRestorePromise.then(() => { - if (!this._disposed && this._visualSettings === settings) - applyPendingVisualChoices(settings, pending); - }); - } - } catch (error) { - await this._detachScene().catch((cleanupError) => - console.error( - 'The shell could not leave a failed globe:', - cleanupError, - ), - ); - throw error; - } - } - - /** - * Detach the shell from its globe (an engine switch, or the page going). - * The parts bound to that globe are released; the shell, its stores and - * the open panels stay. `handoff` keeps the selection and a retainable - * Context mode for the next globe. - */ - detachScene(options = {}) { - return this._queueSceneTransition(() => this._detachScene(options)); - } - - async _detachScene({ - handoff = Boolean(this._layerSession?.handoffLayers), - } = {}) { - if (!this._scene.engine && !this._scene.viewer) return; - const { destroyTrackedReadout, destroyWorldOverlay, destroyDetection } = - this.services; - this._shareRestoration.destroy(); - this._navigation.setScene({}, { retainScene: handoff }); - this._layerBindings.stop(); - this._cockpitCoordinator.stop(); - this._visualSettings.stop(); - // A Context mode that cannot be carried to the next globe (a transition - // in flight) restores the layers it changed, as a teardown does. - if (!this._contextControls.canRetainScene()) { - this._contextControls._contextModeGeneration++; - await this._contextControls.restoreForDisposal(); - this._contextControls._contextModeChanging = false; - } - // IR boost teardown BEFORE detaching the data manager: restore fog and - // un-boost both aircraft layers so the next globe does not inherit - // sensor state (review P2, 2026-08-16). - this._visualSettings.releaseIrBoost(); - this._cockpitCoordinator.destroy(); - this._layerBindings.disconnect(); - destroyTrackedReadout(); - destroyDetection(); - destroyWorldOverlay(); - this.celestialRing?.destroy(); - this.celestialRing = null; - this._visualSettings.destroy(); - // Until the next globe: choices wait for it (pendingVisualSettings.js), - // and the map-source chips and camera buttons answer nothing. - this._pendingVisual = pendingVisualSettings(this._visualSettings); - this._visualSettings = this._pendingVisual.settings; - this._mapSourceControls?.destroy(); - this._mapSourceControls = null; - useDisplayStore(applicationPinia()).connectMapSource(null); - this._cameraOrientationControls?.destroy(); - this._cameraOrientationControls = null; - if (this._orientationToken) - useGlobeActionsStore(applicationPinia()).detach(this._orientationToken); - this._orientationToken = null; - this._displayBindings.attachViewer(null); - this._locationNavigation.detachScene(); - this.hud.attachEngine(null); - this.shareLinkManager.attachEngine(null); - this._scene = { viewer: null, engine: null, mapStackController: null }; - } - - /** - * Rebuild a panel's controls whose layer is another instance in the new - * catalog (a layer this engine left out, then shows). Layers the session - * keeps are the same instances, so their controls stay. - */ - _refreshLayerControls(previous) { - const changed = (...roles) => - roles.some((role) => previous?.[role] !== this.services[role]); - if (changed('cctvLayer')) this._initCctvPanel(); - if (changed('radioLayer', 'localAdsbLayer')) this._initRadioPanel(); - if (changed('streetLevelLayer')) this._initStreetLevelPanel(); - this._initWildlifeSightingCard(); - if ( - changed( - 'radioLayer', - 'militaryInstallationsLayer', - 'militaryAwarenessLayer', - 'rocketLaunchesLayer', - ) - ) { - this._contextControls.stop(); - this._contextControls.disconnect(); - this._initGlobalContextPanel(); - } - } - /** The attached globe's Cesium viewer; null on Navara or between engines. */ get viewer() { return this._scene?.viewer ?? null; @@ -2074,9 +1623,7 @@ export class StyleManager extends ShellFacade { async dispose() { if (this._disposed) return; const layerSession = this._layerSession; - const handoff = - Boolean(layerSession?.handoffLayers) && - !layerSession.lifetime.signal.aborted; + const handoff = retainsLayerSession(layerSession); // A Context mode the next shell can resume when the session is handed to // a new application. const retainContext = handoff && this._contextControls.canRetainScene(); diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs index b0aea6cfd..2019b9ec8 100644 --- a/src/ui/sceneControls.test.mjs +++ b/src/ui/sceneControls.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import test, { after, before } from 'node:test'; import assert from 'node:assert/strict'; import { assertSameNode } from '../testSupport/domAssert.mjs'; @@ -383,6 +384,7 @@ test('the real director preserves a selected shot label for the following double const f = fixture(); const previousStorage = globalThis.localStorage; let director; + const engine = createFakeEngine(); try { f.owner.destroy(); Object.defineProperty(globalThis, 'localStorage', { @@ -393,7 +395,7 @@ test('the real director preserves a selected shot label for the following double setItem() {}, }, }); - director = new SceneDirector({ camera: { cancelFlight() {} } }, {}, {}); + director = new SceneDirector({ engine }, {}, {}); await nextTick(); const label = f.label(); label.click(); @@ -406,6 +408,7 @@ test('the real director preserves a selected shot label for the following double assert.equal(director._getSelectedScene().shots[0].title, 'Renamed'); } finally { await director?.destroy(); + engine.destroy(); Object.defineProperty(globalThis, 'localStorage', { configurable: true, writable: true, diff --git a/src/ui/shellScene.js b/src/ui/shellScene.js new file mode 100644 index 000000000..96e4f6ab3 --- /dev/null +++ b/src/ui/shellScene.js @@ -0,0 +1,459 @@ +import { retainsLayerSession } from '../app/layerSession.js'; +import { resolveImageryHost } from '../layers/weather/imageryHost.js'; +import { LayerBindings } from './layerBindings.js'; +import { VisualSettings } from './visualSettings.js'; +import { ShareRestoration } from './shareRestoration.js'; +import { CockpitCoordinator } from './cockpitCoordinator.js'; +import { useCelestialRingStore } from './stores/celestialRing.js'; +import { + applyPendingVisualChoices, + pendingVisualSettings, +} from './pendingVisualSettings.js'; +import { ShellFacade } from './shellFacade.js'; +import { applyShareFollowOffset } from './shareFollow.js'; +import { applicationPinia } from './vueRuntime.js'; +import { useCockpitStore } from './stores/cockpit.js'; +import { useDisplayStore } from './stores/display.js'; +import { useGlobeActionsStore } from './stores/globeActions.js'; + +/** Globe-bound composition kept separately from the page shell. */ +export class ShellScene extends ShellFacade { + // ── Scene attachment ────────────────────────────────────────────────── + // The shell lives for the page; an engine switch only rebuilds the parts + // bound to one globe (share restoration, visual effects, layer bindings, + // Cockpit, the celestial ring, the overlay host, detection, the map-source + // and camera-orientation controls) and points the kept ones (navigation, + // location search, HUD, share link, display bindings) at the next globe. + + /** Share restoration, visual settings and layer bindings of this globe. */ + _buildSceneControls() { + const { viewer, engine, mapStackController } = this._scene; + const services = this.services; + const layerSession = this._layerSession; + this._shareRestoration = new ShareRestoration({ + engine, + canvas: engine?.host.canvas ?? viewer?.canvas, + handoffLayers: layerSession?.handoffLayers, + navigation: this._navigation, + syncShareState: () => this._syncShareState(), + syncModels3d: (state) => this._syncModels3dFromLayerState(state), + showStatus: (message, options) => + this._showGlobalStatusNotice(message, options), + feedback: this._feedback, + updateFeedback: () => this._updateGlobalLoadingFeedback(), + applyFollowOffset: (handle, offset) => + applyShareFollowOffset(engine, handle, offset), + }); + + this._visualSettings = new VisualSettings({ + viewer, + engine, + mapStackController, + services: { + setScopeTerminusOverride: services.setScopeTerminusOverride, + clampScopeTerminusPct: services.clampScopeTerminusPct, + getDetectionMode: services.getDetectionMode, + getDetectionTuning: services.getDetectionTuning, + getKeyholeFadeTuning: services.getKeyholeFadeTuning, + getScopeMaskFeather: services.getScopeMaskFeather, + getScopeTerminusOverride: services.getScopeTerminusOverride, + governorRequestRender: services.governorRequestRender, + holdContinuousRender: services.holdContinuousRender, + isCelestialRingStyleSupported: services.isCelestialRingStyleSupported, + isScopeMaskEnabled: services.isScopeMaskEnabled, + readDetectionDiagnostics: services.readDetectionDiagnostics, + releaseContinuousRender: services.releaseContinuousRender, + setDetectionModeByLabel: services.setDetectionModeByLabel, + setDetectionStyle: services.setDetectionStyle, + setDetectionTuning: services.setDetectionTuning, + setKeyholeFadeTuning: services.setKeyholeFadeTuning, + setScopeMaskEnabled: services.setScopeMaskEnabled, + setScopeMaskFeather: services.setScopeMaskFeather, + }, + elements: { + _ppToggles: this._ppToggles, + _sliderPanel: this._sliderPanel, + }, + operations: { + _restorePanelState: (...args) => this._restorePanelState(...args), + _layoutRightPanels: (...args) => this._layoutRightPanels(...args), + _scheduleAdaptivePanelLayout: (...args) => + this._scheduleAdaptivePanelLayout(...args), + _scheduleRightPanelLayout: (...args) => + this._scheduleRightPanelLayout(...args), + _setCockpitDisclosure: (...args) => this._setCockpitDisclosure(...args), + _cockpitDisclosureOpen: (kind) => this._cockpitDisclosureOpen(kind), + _setMapStack: (...args) => this._setMapStack(...args), + _presentPanel: (...args) => this._presentPanel(...args), + _syncShareState: (...args) => this._syncShareState(...args), + setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), + }, + readHud: () => this.hud, + readCockpit: () => this.cockpitView, + readDataManager: () => this._dataManager, + readShareLinks: () => this.shareLinkManager, + readCelestialRing: () => this.celestialRing, + readContextMode: () => this._contextMode, + readContextChanging: () => this._contextModeChanging, + readDisplayPortalActive: () => this._cockpitDisplayPortalActive, + }); + + this._layerBindings = new LayerBindings({ + viewer, + engine, + services: { + imageryHost: () => + resolveImageryHost({ + viewer, + tileset: mapStackController?.getImageryHostTileset?.(), + }), + cachedGroundFloor: services.cachedGroundFloor, + warmGroundFloor: services.warmGroundFloor, + cctvLayer: services.cctvLayer, + }, + readControls: () => ({ + hud: this.hud, + _contextControls: this._contextControls, + _cctvControls: this._cctvControls, + _radioControls: this._radioControls, + _streetLevelControls: this._streetLevelControls, + }), + operations: { + _updateTrafficSyncChip: (...args) => + this._updateTrafficSyncChip(...args), + _updateGlobalLoadingFeedback: (...args) => + this._updateGlobalLoadingFeedback(...args), + _syncContextModeButtons: (...args) => + this._syncContextModeButtons(...args), + _stampNavigation: (...args) => this._stampNavigation(...args), + _runExplicitCctvFocus: (...args) => this._runExplicitCctvFocus(...args), + _runExplicitWorldFocus: (...args) => + this._runExplicitWorldFocus(...args), + runImmediateNavigation: (...args) => + this.runImmediateNavigation(...args), + _showToast: (...args) => this._showToast(...args), + }, + feedback: this._feedback, + shareRestoration: this._shareRestoration, + }); + } + + /** Cockpit and the celestial ring of this globe. */ + _buildSceneCockpit() { + const { viewer, engine } = this._scene; + const services = this.services; + const { CelestialRing } = services; + this._cockpitCoordinator = new CockpitCoordinator({ + viewer, + engine, + services: { + flightsLayer: services.flightsLayer, + militaryFlightsLayer: services.militaryFlightsLayer, + isTr3b: services.isTr3b, + toggleTr3b: services.toggleTr3b, + militaryAwarenessLayer: services.militaryAwarenessLayer, + cachedGroundFloor: services.cachedGroundFloor, + cachedMeshFloor: services.cachedMeshFloor, + GROUND_FLOOR_LIFT_M: services.GROUND_FLOOR_LIFT_M, + meshFloorPreferred: services.meshFloorPreferred, + warmGroundFloor: services.warmGroundFloor, + sampleMeshFloorCells: services.sampleMeshFloorCells, + holdContinuousRender: services.holdContinuousRender, + releaseContinuousRender: services.releaseContinuousRender, + fetchRegionalBrief: services.fetchRegionalBrief, + regionalDistanceM: services.regionalDistanceM, + weatherCodeLabel: services.weatherCodeLabel, + }, + elements: { + _ppToggles: this._ppToggles, + _cockpitDisplayPanel: this._cockpitDisplayPanel, + _hudBtn: this._hudBtn, + _detectionBtn: this._detectionBtn, + _sliderPanel: this._sliderPanel, + _models3dBtn: this._models3dBtn, + }, + operations: { + publishCockpit: { + presence: (view) => + useCockpitStore(applicationPinia()).publishPresence(view), + brief: (view) => + useCockpitStore(applicationPinia()).publishBrief(view), + context: (view) => + useCockpitStore(applicationPinia()).publishContext(view), + instruments: (view) => + useCockpitStore(applicationPinia()).publishInstruments(view), + route: (view) => + useCockpitStore(applicationPinia()).publishRoute(view), + vision: (view) => + useCockpitStore(applicationPinia()).publishVision(view), + signals: (rows) => + useCockpitStore(applicationPinia()).publishSignals(rows), + utilityLayout: (layout) => + useCockpitStore(applicationPinia()).publishUtilityLayout(layout), + }, + publishCockpitPortal: (slots) => + useDisplayStore(applicationPinia()).publishCockpitPortal(slots), + _layoutRightPanels: (...args) => this._layoutRightPanels(...args), + _setCockpitVision: (...args) => this._setCockpitVision(...args), + _stampNavigation: (...args) => this._stampNavigation(...args), + getAircraftTrackingTarget: (...args) => + this.getAircraftTrackingTarget(...args), + _scheduleRightPanelLayout: (...args) => + this._scheduleRightPanelLayout(...args), + }, + readDataManager: () => this._dataManager, + readContext: () => this.getContextModeState(), + readActiveStyle: () => this.activeStyle, + readUtilityDisclosureOpen: () => + this._cockpitDisclosureOpen('display') || + this._cockpitDisclosureOpen('radio'), + enterPanels: () => this._panelChrome.enterCockpit(), + exitPanels: () => this._panelChrome.exitCockpit(), + }); + + // Full-globe sun/moon ring on either engine, below the HUD/readout. + this.celestialRing = new CelestialRing(viewer ?? engine, { + engine, + enabled: false, + store: useCelestialRingStore(applicationPinia()), + onAutoDisable: () => + this.setCelestialRingEnabled(false, { + syncShare: !!this.shareLinkManager, + focus: false, + }), + }); + } + + /** The world-overlay host, detection and post-process stages of this globe. */ + _initSceneOverlays() { + const { engine } = this._scene; + const { + initWorldOverlay, + initDetection, + initTrackedReadout, + setDetectionStyle, + trafficLayer, + flightsLayer, + militaryFlightsLayer, + localAdsbLayer, + satellitesLayer, + cctvLayer, + bikeshareLayer, + transitLayer, + aisLiveVesselsLayer, + } = this.services; + // The shared world-overlay host must own its one postRender lane before + // detection and tracked-readout initialize. It stays transparent until a + // production source explicitly registers entries. + initWorldOverlay(engine); + // Initialize detection overlay BEFORE style stages so the composite + // stage is first in the post-process pipeline. Both run on the engine, + // Navara's too. + { + initDetection( + engine, + [ + trafficLayer, + flightsLayer, + militaryFlightsLayer, + localAdsbLayer, + satellitesLayer, + cctvLayer, + bikeshareLayer, + transitLayer, + aisLiveVesselsLayer, + ].filter(Boolean), + (modeLabel) => { + this._updateDetectionButton(modeLabel); + }, + ); + initTrackedReadout(engine); + } + setDetectionStyle(this.activeStyle); + this._setDetectionDensity(); + + this._initStages(); + this._initBloomSharpen(); + } + + /** + * Attach the shell to another globe (an engine switch): `{ viewer, engine, + * mapStackController, services }`, where `services` are that catalog's + * layer roles. The shell, its stores, open panels, typed text and focus + * stay; the parts bound to a globe are built for this one and the + * address's view is restored on it. + */ + attachScene(options = {}) { + return this._queueSceneTransition(() => this._attachScene(options)); + } + + _queueSceneTransition(work) { + const run = (this._sceneTransition ?? Promise.resolve()).then(work, work); + this._sceneTransition = run.catch(() => {}); + return run; + } + + async _attachScene({ + viewer = null, + engine = null, + mapStackController = null, + services = {}, + engineSwitch = true, + } = {}) { + if (this._disposed) return; + if (this._scene.engine === engine && this._scene.viewer === viewer) return; + if (this._scene.engine) + throw new Error('The shell is attached to a globe already'); + // An attachment that fails part-way releases what it built: the shell + // stays detached, ready for the working engine. + try { + const previousServices = this.services; + this.services = { ...this.services, ...services }; + this._scene = { viewer, engine, mapStackController }; + const { + flightsLayer, + militaryFlightsLayer, + satellitesLayer, + aisLiveVesselsLayer, + militaryAwarenessLayer, + rocketLaunchesLayer, + } = this.services; + this._navigation.tracking = { + flightsLayer, + militaryFlightsLayer, + satellitesLayer, + aisLiveVesselsLayer, + militaryAwarenessLayer, + rocketLaunchesLayer, + }; + this._navigation.setScene({ viewer, engine }); + this._locationNavigation.attachScene({ viewer, engine }); + this.hud.attachEngine(engine, { viewer }); + this.shareLinkManager.attachEngine(engine); + this._displayBindings.attachViewer(viewer); + this._buildSceneControls(); + this._buildSceneCockpit(); + this._shareRestoration.attachLinks(this.shareLinkManager); + this._initSceneOverlays(); + this._initMapStackControl(); + this._refreshLayerControls(previousServices); + this._initCameraOrientationControls(); + this._initHudScene(); + this._initCockpitDisplayPortal(); + this._applyGlobalPostDefaults(); + this._startAnimationLoop(); + this._publishActiveStyle(); + this._shareRestoration.start({ + engineSwitch, + retainScene: Boolean(this._layerSession?.handoffLayers), + }); + this._layerBindings.observeCamera(); + this._layoutRightPanels(); + this._syncCctvPanelViewport(); + // Choices made between the engines apply once this engine restored + // the address's view: the person's choice wins over the link. + const pending = this._pendingVisual?.queue; + this._pendingVisual = null; + if (pending?.length) { + const settings = this._visualSettings; + void this.initialRestorePromise.then(() => { + if (!this._disposed && this._visualSettings === settings) + applyPendingVisualChoices(settings, pending); + }); + } + } catch (error) { + await this._detachScene().catch((cleanupError) => + console.error( + 'The shell could not leave a failed globe:', + cleanupError, + ), + ); + throw error; + } + } + + /** + * Detach the shell from its globe (an engine switch, or the page going). + * The parts bound to that globe are released; the shell, its stores and + * the open panels stay. `handoff` keeps the selection and a retainable + * Context mode for the next globe. + */ + detachScene(options = {}) { + return this._queueSceneTransition(() => this._detachScene(options)); + } + + async _detachScene({ + handoff = retainsLayerSession(this._layerSession), + } = {}) { + if (!this._scene.engine && !this._scene.viewer) return; + const { destroyTrackedReadout, destroyWorldOverlay, destroyDetection } = + this.services; + this._shareRestoration.destroy(); + this._navigation.setScene({}, { retainScene: handoff }); + this._layerBindings.stop(); + this._cockpitCoordinator.stop(); + this._visualSettings.stop(); + // A Context mode that cannot be carried to the next globe (a transition + // in flight) restores the layers it changed, as a teardown does. + if (!this._contextControls.canRetainScene()) { + this._contextControls._contextModeGeneration++; + await this._contextControls.restoreForDisposal(); + this._contextControls._contextModeChanging = false; + } + // IR boost teardown BEFORE detaching the data manager: restore fog and + // un-boost both aircraft layers so the next globe does not inherit + // sensor state. + this._visualSettings.releaseIrBoost(); + this._cockpitCoordinator.destroy(); + this._layerBindings.disconnect(); + destroyTrackedReadout(); + destroyDetection(); + destroyWorldOverlay(); + this.celestialRing?.destroy(); + this.celestialRing = null; + this._visualSettings.destroy(); + // Until the next globe: choices wait for it (pendingVisualSettings.js), + // and the map-source chips and camera buttons answer nothing. + this._pendingVisual = pendingVisualSettings(this._visualSettings); + this._visualSettings = this._pendingVisual.settings; + this._mapSourceControls?.destroy(); + this._mapSourceControls = null; + useDisplayStore(applicationPinia()).connectMapSource(null); + this._cameraOrientationControls?.destroy(); + this._cameraOrientationControls = null; + if (this._orientationToken) + useGlobeActionsStore(applicationPinia()).detach(this._orientationToken); + this._orientationToken = null; + this._displayBindings.attachViewer(null); + this._locationNavigation.detachScene(); + this.hud.attachEngine(null); + this.shareLinkManager.attachEngine(null); + this._scene = { viewer: null, engine: null, mapStackController: null }; + } + + /** + * Rebuild a panel's controls whose layer is another instance in the new + * catalog (a layer this engine left out, then shows). Layers the session + * keeps are the same instances, so their controls stay. + */ + _refreshLayerControls(previous) { + const changed = (...roles) => + roles.some((role) => previous?.[role] !== this.services[role]); + if (changed('cctvLayer')) this._initCctvPanel(); + if (changed('radioLayer', 'localAdsbLayer')) this._initRadioPanel(); + if (changed('streetLevelLayer')) this._initStreetLevelPanel(); + this._initWildlifeSightingCard(); + if ( + changed( + 'radioLayer', + 'militaryInstallationsLayer', + 'militaryAwarenessLayer', + 'rocketLaunchesLayer', + ) + ) { + this._contextControls.stop(); + this._contextControls.disconnect(); + this._initGlobalContextPanel(); + } + } +}