diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9a0525970..d9849ed78 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1072,6 +1072,7 @@ "src/layers/weather/imageryHost.js", "src/ui/layerBindings.js", "src/ui/displayBindings.js", + "src/ui/pendingVisualSettings.js", "src/ui/shellFacade.js", "src/ui/streetLevelControls.js", "src/ui/streetLevelPresentation.js", @@ -3311,6 +3312,7 @@ "src/ui/aircraftDisplay.js", "src/ui/layerBindings.js", "src/ui/displayBindings.js", + "src/ui/pendingVisualSettings.js", "src/ui/shellFacade.js", "src/voice/realtimePreferences.js", "src/voice/realtimeProtocol.js", diff --git a/src/sceneAttachEngine.test.mjs b/src/sceneAttachEngine.test.mjs index 552214c60..cf884f78e 100644 --- a/src/sceneAttachEngine.test.mjs +++ b/src/sceneAttachEngine.test.mjs @@ -86,3 +86,19 @@ test('navigation authority retires deferred flights when the globe changes', () const next = navigation._beginDeferredNavigation('location'); assert.equal(navigation._reassertNavigationHandoff(next), true); }); + +test('between engines the globe reset neither stamps navigation nor reads a camera', async () => { + const { LocationNavigation } = await import('./ui/locationNavigation.js'); + const location = Object.create(LocationNavigation.prototype); + let stamped = 0; + Object.assign(location, { + services: { interruptCameraMotion() {} }, + engine: null, + _disposed: false, + _stampNavigation: () => stamped++, + }); + const result = await location.resetToGlobeView(); + assert.equal(result.ok, false); + assert.equal(result.cancelled, true); + assert.equal(stamped, 0); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 53f3130db..c7af4bfc2 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -9,6 +9,10 @@ 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'; @@ -754,6 +758,17 @@ export class StyleManager extends ShellFacade { 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) { void this.detachScene().catch((cleanupError) => console.error( @@ -809,10 +824,18 @@ export class StyleManager extends ShellFacade { 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); diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 96c753fee..60d5e6cf6 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -135,11 +135,16 @@ export class LocationNavigation { isCurrent: (generation) => !this._disposed && generation === this._navigationGeneration, beforeFly: (generation) => this._reassertNavigationHandoff(generation), + // The engine the lookup ends on: an answer that lands after a switch + // flies the globe shown then; one started with none attached is + // cancelled. search: (query, options) => - searchAndFlyTo(this.engine, query, { - placeSearch: this.placeSearch, - ...options, - }), + this.engine + ? searchAndFlyTo(this.engine, query, { + placeSearch: this.placeSearch, + ...options, + }) + : Promise.resolve({ cancelled: true }), onResult: (destination) => { if (destination.outlineUnavailable) this._showToast(t('Detailed outline unavailable')); @@ -334,7 +339,9 @@ export class LocationNavigation { militaryAwarenessLayer, rocketLaunchesLayer, } = this.services; - if (this._disposed) + // Between two globe engines there is no camera to reset: nothing moves + // and no navigation is stamped. + if (this._disposed || !this.engine) return Promise.resolve({ ok: false, action: 'zoom_to_globe', diff --git a/src/ui/pendingVisualSettings.js b/src/ui/pendingVisualSettings.js new file mode 100644 index 000000000..86b663490 --- /dev/null +++ b/src/ui/pendingVisualSettings.js @@ -0,0 +1,68 @@ +// Between two globe engines the shell has no visual settings of its own: +// the outgoing ones are released and the incoming ones are not built yet. +// A choice the person makes meanwhile (a style key, a Display control, a +// voice command) is kept and applied to the next engine's settings once +// that engine has restored the address's view, so the person's choice wins. +// Reads answer from the outgoing settings' last state. + +/** The visual choices kept for the next engine, by method name. */ +export const PENDING_VISUAL_CHOICES = Object.freeze( + new Set([ + 'setStyle', + 'setBloom', + 'setSharpen', + 'setDetection', + 'setCelestialRingEnabled', + 'setStyleParameter', + 'applyVisualState', + 'applyCinematicPreset', + '_setBloomEnabled', + '_setBloomIntensity', + '_setSharpenEnabled', + '_setSharpenIntensity', + '_setScope', + '_setHudVariant', + '_setDetectionDensity', + '_setDetectionAllocation', + '_setDetectionFade', + '_setDetectionModeByLabel', + '_setCyberSonarEnabled', + '_setCyberSonarSetting', + ]), +); + +/** + * Stand in for released visual settings: choices are queued (`queue`), + * everything else reads the released settings. + * @param {object} released The outgoing engine's VisualSettings. + * @returns {{settings: object, queue: Array<[string, unknown[]]>}} + */ +export function pendingVisualSettings(released) { + const queue = []; + const settings = new Proxy(released, { + get(target, name) { + if (PENDING_VISUAL_CHOICES.has(name)) + return (...args) => { + queue.push([name, args]); + return { ok: true, pending: true }; + }; + const value = Reflect.get(target, name, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + return { settings, queue }; +} + +/** Apply the queued choices, in order, to the next engine's settings. */ +export function applyPendingVisualChoices(settings, queue) { + for (const [name, args] of queue.splice(0)) { + try { + settings[name]?.(...args); + } catch (error) { + console.error( + `A visual choice made during the switch failed (${name}):`, + error, + ); + } + } +} diff --git a/src/ui/pendingVisualSettings.test.mjs b/src/ui/pendingVisualSettings.test.mjs new file mode 100644 index 000000000..9434f9cf3 --- /dev/null +++ b/src/ui/pendingVisualSettings.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyPendingVisualChoices, + pendingVisualSettings, +} from './pendingVisualSettings.js'; + +test('a style chosen between engines waits and then wins over the restored view', () => { + const calls = []; + const released = { + activeStyle: 'nvg', + setStyle(name) { + calls.push(['released', name]); + }, + _readShareState: () => ({ style: 'nvg' }), + }; + const { settings, queue } = pendingVisualSettings(released); + // Reads answer from the released settings; choices do not reach them. + assert.equal(settings.activeStyle, 'nvg'); + assert.deepEqual(settings._readShareState(), { style: 'nvg' }); + assert.deepEqual(settings.setStyle('crt', { applyPreset: true }), { + ok: true, + pending: true, + }); + settings._setBloomEnabled(true); + assert.deepEqual(calls, []); + // The next engine restored the link's 'normal'; the choices then apply. + const next = { + activeStyle: 'normal', + setStyle(name) { + calls.push(['next', name]); + this.activeStyle = name; + }, + _setBloomEnabled(on) { + calls.push(['next bloom', on]); + }, + }; + applyPendingVisualChoices(next, queue); + assert.deepEqual(calls, [ + ['next', 'crt'], + ['next bloom', true], + ]); + assert.equal(next.activeStyle, 'crt'); + assert.equal(queue.length, 0); +}); diff --git a/src/ui/shellSceneSwap.test.mjs b/src/ui/shellSceneSwap.test.mjs index 69018f2ad..40ece5654 100644 --- a/src/ui/shellSceneSwap.test.mjs +++ b/src/ui/shellSceneSwap.test.mjs @@ -216,3 +216,30 @@ test('an attachment that throws part-way leaves the shell detached and attachabl assert.equal(shell.engine, engine); assert.ok(log.includes('shareRestoration.start')); }); + +test('between engines a style key waits for the next engine instead of the released settings', async () => { + const { shell } = fakeShell(); + const released = []; + shell._visualSettings = { + stop() {}, + releaseIrBoost() {}, + destroy() {}, + setStyle: (name) => released.push(name), + }; + Object.defineProperty(shell, 'initialRestorePromise', { + value: Promise.resolve(), + }); + await shell.detachScene({ handoff: true }); + shell.setStyle('crt'); + assert.deepEqual(released, [], 'the released settings do not change'); + const applied = []; + shell._buildSceneControls = () => { + shell._shareRestoration = { attachLinks() {}, start() {}, destroy() {} }; + shell._layerBindings = { observeCamera() {}, stop() {}, disconnect() {} }; + shell._visualSettings = { setStyle: (name) => applied.push(name) }; + }; + shell.attachScene({ engine: { kind: 'cesium' } }); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(applied, ['crt']); +});