Hold the shell's inputs between two engines instead of reaching released controllers

A visual choice made while no globe is attached (a style key, a Display control, a voice command) is queued and applied to the next engine's settings after it restored the address's view, so the person's choice wins; reads answer from the released settings. The globe reset and a location lookup started without a globe do nothing, and the map-source chips and camera buttons are unbound until the next globe binds them.
This commit is contained in:
German Khokhlov committed 2026-11-03 19:54:45 +04:00
1 parent df73c5b21a
commit 40a81c58e6
7 files changed
+193 -5

No files matched your search

+2
View File
@@ -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",
+16
View File
@@ -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);
});
+23
View File
@@ -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);
+12 -5
View File
@@ -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',
+68
View File
@@ -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,
);
}
}
}
+45
View File
@@ -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);
});
+27
View File
@@ -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']);
});