Merge branch 'main' into upstream/traffic-layer-stale

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-10-28 09:05:08 -07:00
commit 065314c07c
7 files changed
+552 -18

No files matched your search

@@ -0,0 +1,4 @@
- Restore a shot's declared layer visibility when seeking within it after manual
layer changes, including toggles still in flight. Only changed layers are
reconciled; refused restoration reports a failed seek and keeps the last
position, while undeclared operator layers are preserved (@oneill9, #1374).
+5
View File
@@ -560,6 +560,11 @@ dateline while preserving both authored endpoint longitudes, and turns heading
and roll the short way for any authored angles. and roll the short way for any authored angles.
A seek to a shot's start, as a shot action makes, selects that shot rather than A seek to a shot's start, as a shot action makes, selects that shot rather than
the end of the previous one. the end of the previous one.
Same-shot seeking restores declared layer visibility after a manual toggle,
including one still in flight, by reconciling only the changed layers through
the cancellable layer admission path. Refused restoration reports a failed
seek and keeps the last settled position. Undeclared operator layers remain
untouched, and the shot is not rebuilt.
See [camera directions](DIRECTOR-CAMERA.md). See [camera directions](DIRECTOR-CAMERA.md).
Realtime voice composes separate connection, response/tool, Radio handoff, input Realtime voice composes separate connection, response/tool, Radio handoff, input
+64
View File
@@ -88,6 +88,70 @@ try {
}; };
}); });
for (const [name, passed] of Object.entries(result)) check(name, passed); for (const [name, passed] of Object.entries(result)) check(name, passed);
const visibilityRestored = await page.evaluate(async () => {
// QA layer registration exists only in Vite dev builds.
if (typeof window.__gevQaRegisterLayer !== 'function') return 'skipped';
const { sceneDirector: director, dataManager } = window.__godsEyeView;
const layerId = 'director-seek-visibility-qa';
window.__gevQaRegisterLayer(dataManager, {
id: layerId,
name: 'Director seek visibility QA',
updateInterval: -1,
init() {},
enable() {},
disable() {},
update() {},
destroy() {},
getStats: () => ({ count: 0 }),
});
const scene = {
id: 'visibility-qa',
title: 'Visibility QA',
shots: [
{
id: 'visibility-shot',
title: 'Visibility shot',
durationSec: 0.4,
holdSec: 0,
camera: director.styleManager.getCameraState(),
visual: director.styleManager.getVisualState(),
layers: { [layerId]: { enabled: true } },
},
],
};
director._project.scenes.push(scene);
try {
const loaded = await director.loadShot(scene.id, scene.shots[0].id, {
flyDuration: 0.2,
});
const disabled =
(await dataManager.setEnabled(layerId, false, { origin: 'user' })) &&
!dataManager.layers.get(layerId).enabled;
const sought = await director.seekScene(scene.id, 0.5);
return (
loaded?.started &&
disabled &&
sought &&
dataManager.layers.get(layerId).enabled
);
} finally {
director.stopScene();
director._project.scenes = director._project.scenes.filter(
({ id }) => id !== scene.id,
);
await window.__gevQaUnregisterLayer(dataManager, layerId);
}
});
if (visibilityRestored === 'skipped') {
console.log(
'[SKIP] same-shot seek visibility needs a Vite dev server (QA_BASE_URL)',
);
} else {
check(
'same-shot seek restores manually changed layer visibility',
visibilityRestored,
);
}
const content = await page.evaluate(async () => { const content = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector; const d = window.__godsEyeView.sceneDirector;
const scene = d._project.scenes.find(({ title }) => const scene = d._project.scenes.find(({ title }) =>
+2
View File
@@ -728,6 +728,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
viewer, viewer,
_sceneSeekGeneration: 0, _sceneSeekGeneration: 0,
_loadGeneration: 0, _loadGeneration: 0,
_bumpSceneSeekGeneration:
SceneDirector.prototype._bumpSceneSeekGeneration,
_cancelActiveSceneTravel: _cancelActiveSceneTravel:
SceneDirector.prototype._cancelActiveSceneTravel, SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback, _setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
@@ -274,7 +274,7 @@ test('settled pack shots cannot take the same-shot seek shortcut after Stop rele
_claimCameraOwnership: () => _claimCameraOwnership: () =>
assert.fail('Must reload the released pack before camera seek'), assert.fail('Must reload the released pack before camera seek'),
}); });
assert.equal(director._seekLoadedShot({ id: 'scene' }, { shot }), false); assert.equal(await director._seekLoadedShot({ id: 'scene' }, { shot }), null);
}); });
test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => { test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => {
+114 -17
View File
@@ -153,6 +153,8 @@ export class SceneDirector {
this._runAbort = null; this._runAbort = null;
/** @type {AbortController|null} Aborts the in-flight LOAD's layer transitions */ /** @type {AbortController|null} Aborts the in-flight LOAD's layer transitions */
this._loadAbort = null; this._loadAbort = null;
/** @type {AbortController|null} Aborts a same-shot seek's layer reconcile */
this._seekAbort = null;
this._clock = createPlaybackClock({ this._clock = createPlaybackClock({
isRunning: () => this._running, isRunning: () => this._running,
timingForShot: (scene, shot) => this._sceneTimingForShot(scene, shot), timingForShot: (scene, shot) => this._sceneTimingForShot(scene, shot),
@@ -234,7 +236,7 @@ export class SceneDirector {
destroy() { destroy() {
if (this._destroyPromise) return this._destroyPromise; if (this._destroyPromise) return this._destroyPromise;
this._destroyed = true; this._destroyed = true;
this._sceneSeekGeneration++; this._bumpSceneSeekGeneration();
this._visibilityUnsubscribe?.(); this._visibilityUnsubscribe?.();
this._cameraHandoffUnsubscribe?.(); this._cameraHandoffUnsubscribe?.();
this._removeCameraInput?.(); this._removeCameraInput?.();
@@ -1071,7 +1073,7 @@ export class SceneDirector {
loadShot(sceneId, shotId, options) { loadShot(sceneId, shotId, options) {
if (this._destroyed) if (this._destroyed)
return Promise.resolve({ started: false, reason: 'destroyed' }); return Promise.resolve({ started: false, reason: 'destroyed' });
this._sceneSeekGeneration++; this._bumpSceneSeekGeneration();
this._interactionTransitions = 0; this._interactionTransitions = 0;
const previousGeneration = this._loadGeneration; const previousGeneration = this._loadGeneration;
const work = this._loadShot(sceneId, shotId, options); const work = this._loadShot(sceneId, shotId, options);
@@ -1516,9 +1518,30 @@ export class SceneDirector {
return new Promise((resolve) => this._runIdleResolvers.add(resolve)); return new Promise((resolve) => this._runIdleResolvers.add(resolve));
} }
/** Apply a new time inside the already-loaded shot without rebuilding its stack. */ /**
_seekLoadedShot(scene, seekState) { * Supersede any pending seek. Only a seek's own layer reconcile is aborted:
if (this._destroyed) return false; * a request that is later rejected must not cancel an in-flight LOAD.
*/
_bumpSceneSeekGeneration() {
if (this._seekAbort) {
this._seekAbort.abort();
if (this._loadAbort === this._seekAbort) this._loadAbort = null;
this._seekAbort = null;
}
return ++this._sceneSeekGeneration;
}
/**
* Apply a new time inside the already-loaded shot without rebuilding its stack.
* @returns {Promise<boolean|null>} Null when the shortcut does not apply and
* LOAD must rebuild the shot; otherwise whether the seek settled.
*/
async _seekLoadedShot(
scene,
seekState,
seekGeneration = this._sceneSeekGeneration,
) {
if (this._destroyed) return null;
const shot = seekState?.shot; const shot = seekState?.shot;
if ( if (
!scene || !scene ||
@@ -1528,17 +1551,77 @@ export class SceneDirector {
this._loadedSceneId !== scene.id || this._loadedSceneId !== scene.id ||
this._selectedShotId !== shot.id this._selectedShotId !== shot.id
) )
return null;
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
return false; return false;
if (!this._claimCameraOwnership()) return false; if (!this._claimCameraOwnership()) return false;
this._cancelActiveSceneTravel(); this._cancelActiveSceneTravel();
this._loadAbort?.abort(); this._loadAbort?.abort();
this._loadAbort = null; this._loadAbort = null;
this._loadGeneration += 1; const generation = ++this._loadGeneration;
this._clock.stopShot(); this._clock.stopShot();
const states = this._layerStatesForShot(scene, shot, { const states = this._layerStatesForShot(scene, shot, {
cameraSettled: seekState.cameraProgress >= 1, cameraSettled: seekState.cameraProgress >= 1,
sceneSeek: seekState, sceneSeek: seekState,
}); });
// A manual toggle, or a superseded LOAD transition whose rollback is still
// settling, leaves declared layers off the shot's stack. Reconcile only
// those through the cancellable admission path.
const staleStates = Object.fromEntries(
Object.entries(states)
.filter(([id, state]) => {
const wantEnabled = !!state?.enabled;
const lifecycle = this.dataManager.getLayerLifecycleState(id);
return (
lifecycle &&
(lifecycle.enabled !== wantEnabled ||
lifecycle.lifecycleState === 'enabling' ||
lifecycle.lifecycleState === 'disabling' ||
lifecycle.uncertain ||
this.dataManager.isEffectivelyEnabled(id) !== wantEnabled)
);
})
.map(([id, state]) => [id, { enabled: !!state?.enabled }]),
);
if (Object.keys(staleStates).length) {
const previousEnabled = new Map(
Object.keys(staleStates).map((id) => [
id,
this.dataManager.isEffectivelyEnabled(id),
]),
);
const controller = new AbortController();
this._loadAbort = controller;
this._seekAbort = controller;
const layerResult = await this._applyLayerStates(
staleStates,
this._loadToken(generation, controller.signal, seekGeneration),
{ stopOnRefusal: true, exitContextMode: false },
);
if (this._loadAbort === controller) this._loadAbort = null;
if (this._seekAbort === controller) this._seekAbort = null;
const superseded = () =>
this._destroyed ||
this._loadAbort ||
this._running ||
seekGeneration !== this._sceneSeekGeneration;
if (layerResult.cancelled || superseded()) return false;
if (layerResult.refused.length) {
// Only a refusal rolls back. A cancelled reconcile leaves layer state
// to whatever superseded it, as a cancelled LOAD does.
for (const id of [...layerResult.applied].reverse()) {
if (superseded()) break;
await this.dataManager.setEnabled(
id,
previousEnabled.get(id) ?? false,
{ origin: 'scene' },
);
}
return false;
}
}
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
return false;
let applied = true; let applied = true;
for (const [layerId, state] of Object.entries(states)) { for (const [layerId, state] of Object.entries(states)) {
if (!state?.enabled || !state.params) continue; if (!state?.enabled || !state.params) continue;
@@ -1550,7 +1633,9 @@ export class SceneDirector {
applied = false; applied = false;
} }
} }
if (!applied) return false; if (!applied) return null;
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
return false;
this._setCameraView( this._setCameraView(
seekState.camera || resolveCameraPose(scene, shot.camera), seekState.camera || resolveCameraPose(scene, shot.camera),
); );
@@ -1572,7 +1657,7 @@ export class SceneDirector {
seekScene(sceneId, progress) { seekScene(sceneId, progress) {
if (this._destroyed) return Promise.resolve(false); if (this._destroyed) return Promise.resolve(false);
this.stopScene(msg('Seeking scene clock')); this.stopScene(msg('Seeking scene clock'));
const generation = ++this._sceneSeekGeneration; const generation = this._bumpSceneSeekGeneration();
return this._trackWork(this._seekScene(sceneId, progress, generation)); return this._trackWork(this._seekScene(sceneId, progress, generation));
} }
@@ -1586,7 +1671,10 @@ export class SceneDirector {
return false; return false;
const seekState = this._sceneSeekState(scene, progress); const seekState = this._sceneSeekState(scene, progress);
if (!seekState) return false; if (!seekState) return false;
if (this._seekLoadedShot(scene, seekState)) return true; const seeked = await this._seekLoadedShot(scene, seekState, generation);
if (this._destroyed || generation !== this._sceneSeekGeneration)
return false;
if (seeked !== null) return seeked;
const result = await this._loadShot(scene.id, seekState.shot.id, { const result = await this._loadShot(scene.id, seekState.shot.id, {
sceneSeek: seekState, sceneSeek: seekState,
}); });
@@ -1606,15 +1694,17 @@ export class SceneDirector {
* @param {AbortSignal} [signal] - Abort signal for this LOAD's manager calls * @param {AbortSignal} [signal] - Abort signal for this LOAD's manager calls
* @returns {{ cancelled: boolean, signal: AbortSignal|undefined }} * @returns {{ cancelled: boolean, signal: AbortSignal|undefined }}
*/ */
_loadToken(generation, signal = undefined) { _loadToken(generation, signal = undefined, seekGeneration = null) {
const director = this; const director = this;
return { return {
signal, signal,
get cancelled() { get cancelled() {
return ( return (
director._destroyed || director._destroyed ||
signal?.aborted || Boolean(signal?.aborted) ||
director._loadGeneration !== generation director._loadGeneration !== generation ||
(seekGeneration !== null &&
director._sceneSeekGeneration !== seekGeneration)
); );
}, },
}; };
@@ -1753,7 +1843,7 @@ export class SceneDirector {
startScene(sceneId, options) { startScene(sceneId, options) {
if (this._destroyed) if (this._destroyed)
return Promise.resolve({ started: false, reason: 'destroyed' }); return Promise.resolve({ started: false, reason: 'destroyed' });
this._sceneSeekGeneration++; this._bumpSceneSeekGeneration();
return this._trackWork(this._startScene(sceneId, options)); return this._trackWork(this._startScene(sceneId, options));
} }
@@ -1919,7 +2009,7 @@ export class SceneDirector {
this._setSceneMediaPlayback(); this._setSceneMediaPlayback();
this._interactions?.clear(); this._interactions?.clear();
this._dataPacks?.clear(); this._dataPacks?.clear();
this._sceneSeekGeneration++; this._bumpSceneSeekGeneration();
this._loadAbort?.abort(); this._loadAbort?.abort();
this._loadAbort = null; this._loadAbort = null;
this._loadGeneration++; this._loadGeneration++;
@@ -2165,7 +2255,11 @@ export class SceneDirector {
* the transition in flight. * the transition in flight.
* @returns {Promise<{ applied: string[], refused: string[], cancelled: boolean }>} * @returns {Promise<{ applied: string[], refused: string[], cancelled: boolean }>}
*/ */
async _applyLayerStates(targetStates, token = null) { async _applyLayerStates(
targetStates,
token = null,
{ stopOnRefusal = false, exitContextMode = true } = {},
) {
const applied = []; const applied = [];
const refused = []; const refused = [];
const abort = () => ({ applied, refused, cancelled: true }); const abort = () => ({ applied, refused, cancelled: true });
@@ -2175,8 +2269,10 @@ export class SceneDirector {
// the operator's pre-mode state, which is exactly where a stopped scene // the operator's pre-mode state, which is exactly where a stopped scene
// should come to rest. Tearing that transaction in half would strand // should come to rest. Tearing that transaction in half would strand
// Context, so it completes and cancellation is honoured immediately after. // Context, so it completes and cancellation is honoured immediately after.
await this._exitIsolatingContextMode(); if (exitContextMode) {
if (token?.cancelled) return abort(); await this._exitIsolatingContextMode();
if (token?.cancelled) return abort();
}
const signal = token?.signal; const signal = token?.signal;
const registered = new Set( const registered = new Set(
@@ -2206,6 +2302,7 @@ export class SceneDirector {
console.warn( console.warn(
`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`, `[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`,
); );
if (stopOnRefusal) break;
continue; continue;
} }
if (params && typeof this.dataManager.restoreLayerState !== 'function') { if (params && typeof this.dataManager.restoreLayerState !== 'function') {
+362
View File
@@ -14,6 +14,7 @@ import { readShellSource } from '../testSupport/readShellSource.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { LayerLifecycle } from '../data/lifecycle.js';
import { SceneDirector } from './director.js'; import { SceneDirector } from './director.js';
import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js'; import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js';
import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js'; import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js';
@@ -174,6 +175,348 @@ test('scene clock seek resolves the exact shot phase and camera in both directio
} }
}); });
test('same-shot seeking restores declared layer visibility after manual changes', async () => {
const { director, dataManager, styleManager, restore } = makeDirector();
try {
const shot = director._project.scenes[0].shots[0];
shot.layers = { flights: { enabled: true }, traffic: { enabled: false } };
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
await dataManager.setEnabled('flights', false);
await dataManager.setEnabled('traffic', true);
await dataManager.setEnabled('satellites', true);
dataManager.setEnabledCalls.length = 0;
const visualCalls = styleManager.visualCalls.length;
assert.equal(await director.seekScene('scene-1', 0.25), true);
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25);
const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled]));
assert.equal(enabled.get('flights'), true, 'restore the shot\'s enabled layer');
assert.equal(enabled.get('traffic'), false, 'restore the shot\'s disabled layer');
assert.equal(enabled.get('satellites'), true, 'preserve undeclared operator layers');
assert.ok(dataManager.setEnabledCalls.every(({ id }) => id !== 'satellites'));
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking reports refusal when a manually disabled layer cannot be restored', async () => {
let refuse = false;
const { director, dataManager, styleManager, restore } = makeDirector({
data: { refuse: () => refuse },
});
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(await director.seekScene('scene-1', 0.1), true);
await dataManager.setEnabled('flights', false);
refuse = true;
const visualCalls = styleManager.visualCalls.length;
assert.equal(await director.seekScene('scene-1', 0.25), false);
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, false);
const timing = director.getPlaybackTimingState();
assert.equal(timing.activeTimers, 0);
assert.ok(Math.abs(timing.snapshot.sceneProgress - 0.1) < 1e-9, 'keep the last settled seek position');
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking restores visibility a manual toggle is still changing', async () => {
const { director, dataManager, restore } = makeDirector();
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
const disabling = dataManager.setEnabled('flights', false);
assert.equal(await director.seekScene('scene-1', 0.25), true);
await disabling;
await settle();
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true);
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking restores a layer whose superseded LOAD transition rolls back', async () => {
const { director, dataManager, styleManager, viewer, restore } = makeDirector();
const lifecycle = new LayerLifecycle(viewer);
for (const id of ['flights', 'traffic', 'satellites']) {
lifecycle.register({
id,
name: id,
init() {},
enable() {},
disable() {},
update() {},
});
}
const realDirector = new SceneDirector(viewer, styleManager, lifecycle);
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
await dataManager.setEnabled('flights', false);
dataManager.setEnabledCalls.length = 0;
const reload = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
for (let i = 0; i < 20 && !dataManager.setEnabledCalls.length; i++) await Promise.resolve();
assert.equal(dataManager.getLayerLifecycleState('flights').lifecycleState, 'enabling');
assert.equal(await director.seekScene('scene-1', 0.25), true);
await reload;
await settle();
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true);
await realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
await lifecycle.setEnabled('flights', false);
const queuedReload = realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
for (let i = 0; i < 20 && !lifecycle.isEffectivelyEnabled('flights'); i++) {
await Promise.resolve();
}
assert.deepEqual(lifecycle.getLayerLifecycleState('flights'), {
enabled: false,
lifecycleState: 'disabled',
uncertain: false,
});
assert.equal(await realDirector.seekScene('scene-1', 0.25), true);
await queuedReload;
await settle();
assert.equal(lifecycle.isEnabled('flights'), true);
lifecycle.layers.get('flights').lifecycleUncertain = true;
assert.equal(await realDirector.seekScene('scene-1', 0.3), true);
assert.equal(lifecycle.getLayerLifecycleState('flights').uncertain, false);
let navClaims = 0;
styleManager.runImmediateNavigation = () => {
navClaims += 1;
return false;
};
assert.equal(await realDirector.seekScene('scene-1', 0.4), false);
assert.equal(navClaims, 1, 'camera refusal fails the seek without falling back to LOAD');
} finally {
await realDirector.destroy();
await director.destroy();
restore();
}
});
test('same-shot seeking rolls back earlier layers and preserves Context, clock and camera when a later layer is refused', async () => {
let refuse = () => false;
const { director, dataManager, styleManager, viewer, restore } = makeDirector({
data: { refuse: (id, on) => refuse(id, on) },
});
try {
const shot = director._project.scenes[0].shots[0];
shot.layers = {
flights: { enabled: true },
traffic: { enabled: true },
satellites: { enabled: true },
};
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(await director.seekScene('scene-1', 0.1), true);
await dataManager.setEnabled('flights', false);
await dataManager.setEnabled('traffic', false);
await dataManager.setEnabled('satellites', false);
styleManager.contextMode = 'space-missions';
styleManager.contextExits.length = 0;
refuse = (id) => id === 'traffic';
dataManager.setEnabledCalls.length = 0;
const viewsBefore = viewer.views.length;
const visualCalls = styleManager.visualCalls.length;
assert.equal(await director.seekScene('scene-1', 0.25), false);
assert.equal(
dataManager.setEnabledCalls.some(({ id }) => id === 'satellites'),
false,
'stop the plan before later ids are written',
);
const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled]));
assert.equal(enabled.get('flights'), false, 'first stale layer stays disabled');
assert.equal(enabled.get('traffic'), false, 'refused second layer stays disabled');
assert.equal(enabled.get('satellites'), false, 'later stale layer is never written');
assert.equal(styleManager.contextMode, 'space-missions');
assert.deepEqual(styleManager.contextExits, []);
assert.equal(viewer.views.length, viewsBefore, 'camera stays at the pre-seek pose');
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
assert.ok(
Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9,
'clock stays at the last settled seek position',
);
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking aborts in-flight admission when superseded by an early-return load or start', async () => {
const { styleManager, viewer, restore } = makeDirector();
let holdEnable = false;
let releaseEnable;
const enableGate = new Promise((resolve) => {
releaseEnable = resolve;
});
const lifecycle = new LayerLifecycle(viewer);
lifecycle.register({
id: 'flights',
name: 'flights',
init() {},
async enable() {
if (holdEnable) await enableGate;
},
disable() {},
update() {},
});
const director = new SceneDirector(viewer, styleManager, lifecycle);
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(await director.seekScene('scene-1', 0.1), true);
await lifecycle.setEnabled('flights', false);
holdEnable = true;
const viewsBefore = viewer.views.length;
const seek = director.seekScene('scene-1', 0.25);
for (
let i = 0;
i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling';
i++
) {
await Promise.resolve();
}
assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling');
const admissionSignal = director._loadAbort?.signal;
assert.ok(admissionSignal, 'same-shot seek installs an admission controller');
const missingLoad = director.loadShot('missing', 'nope');
assert.equal(
admissionSignal.aborted,
true,
'missing load aborts the admission controller before returning',
);
assert.equal((await missingLoad).reason, 'shot-not-found');
assert.equal(viewer.views.length, viewsBefore, 'shortcut has not committed before abort');
releaseEnable();
assert.equal(await seek, false);
await settle();
assert.equal(lifecycle.isEnabled('flights'), false, 'layer is not restored');
assert.equal(viewer.views.length, viewsBefore, 'setView is not called');
assert.ok(
Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9,
'sceneProgress stays at the last settled value',
);
let loadFallbackCalls = 0;
const origLoadShot = director._loadShot.bind(director);
director._loadShot = (...args) => {
loadFallbackCalls += 1;
return origLoadShot(...args);
};
const origSeekLoadedShot = director._seekLoadedShot.bind(director);
director._seekLoadedShot = async () => {
await director.startScene('missing');
return null;
};
assert.equal(await director.seekScene('scene-1', 0.25), false);
assert.equal(loadFallbackCalls, 0, 'stale seek returning null must not start _loadShot');
director._seekLoadedShot = origSeekLoadedShot;
} finally {
releaseEnable?.();
await director.destroy();
restore();
}
});
test('a rejected loadShot does not cancel an in-flight LOAD', async () => {
const { styleManager, viewer, restore } = makeDirector();
let holdEnable = true;
let releaseEnable;
const enableGate = new Promise((resolve) => {
releaseEnable = resolve;
});
const lifecycle = new LayerLifecycle(viewer);
lifecycle.register({
id: 'flights',
name: 'flights',
init() {},
async enable() {
if (holdEnable) await enableGate;
},
disable() {},
update() {},
});
const director = new SceneDirector(viewer, styleManager, lifecycle);
try {
const load = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
for (
let i = 0;
i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling';
i++
) {
await Promise.resolve();
}
assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling');
assert.equal((await director.loadShot('missing', 'nope')).reason, 'shot-not-found');
holdEnable = false;
releaseEnable();
assert.equal((await load)?.started, true, 'the valid LOAD still lands');
assert.equal(lifecycle.isEnabled('flights'), true);
} finally {
releaseEnable?.();
await director.destroy();
restore();
}
});
test('refused same-shot seek rollback keeps a manual toggle still in flight', async () => {
let refuse = () => false;
const { director, dataManager, restore } = makeDirector({
data: { refuse: (id, on) => refuse(id, on) },
});
try {
const shot = director._project.scenes[0].shots[0];
shot.layers = { flights: { enabled: true }, traffic: { enabled: true } };
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(await director.seekScene('scene-1', 0.1), true);
await dataManager.setEnabled('flights', false);
await dataManager.setEnabled('traffic', false);
refuse = (id) => id === 'traffic';
const manualEnable = dataManager.setEnabled('flights', true);
assert.equal(await director.seekScene('scene-1', 0.25), false);
await manualEnable;
await settle();
assert.equal(
dataManager.getAll().find(({ id }) => id === 'flights').enabled,
true,
'rollback restores the operator\'s intended state, not the stale committed one',
);
} finally {
await director.destroy();
restore();
}
});
test('same-shot seeking keeps the shortcut when declared visibility is unchanged', async () => {
const { director, dataManager, styleManager, restore } = makeDirector();
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
await dataManager.setEnabled('satellites', true);
dataManager.setEnabledCalls.length = 0;
const visualCalls = styleManager.visualCalls.length;
assert.equal(await director.seekScene('scene-1', 0.25), true);
assert.deepEqual(dataManager.setEnabledCalls, []);
assert.equal(styleManager.visualCalls.length, visualCalls);
assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25);
} finally {
await director.destroy();
restore();
}
});
test('scene clock subscribers receive authoritative forward playback snapshots', () => { test('scene clock subscribers receive authoritative forward playback snapshots', () => {
const { director, restore } = makeDirector(); const { director, restore } = makeDirector();
const snapshots = []; const snapshots = [];
@@ -647,6 +990,7 @@ function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) {
*/ */
function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) { function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) {
const enabled = new Map(); const enabled = new Map();
const pending = new Map();
const setEnabledCalls = []; const setEnabledCalls = [];
const setParamsCalls = []; const setParamsCalls = [];
const committed = []; const committed = [];
@@ -659,12 +1003,25 @@ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {})
setParamsCalls, setParamsCalls,
committed, committed,
getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })), getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })),
getLayerLifecycleState: (id) => {
if (!registered.includes(id)) return null;
const on = !!enabled.get(id);
const target = pending.get(id);
return {
enabled: on,
lifecycleState:
target === undefined ? (on ? 'enabled' : 'disabled') : target ? 'enabling' : 'disabling',
};
},
isEffectivelyEnabled: (id) => pending.get(id) ?? !!enabled.get(id),
getLayerParams: () => null, getLayerParams: () => null,
async setEnabled(id, shouldEnable, { signal } = {}) { async setEnabled(id, shouldEnable, { signal } = {}) {
setEnabledCalls.push({ id, enabled: shouldEnable, signal }); setEnabledCalls.push({ id, enabled: shouldEnable, signal });
if (refuse(id, shouldEnable)) return false; if (refuse(id, shouldEnable)) return false;
// Yield once so a stop landing during the transition is observable. // Yield once so a stop landing during the transition is observable.
pending.set(id, shouldEnable);
await Promise.resolve(); await Promise.resolve();
if (pending.get(id) === shouldEnable) pending.delete(id);
if (signal?.aborted) return false; if (signal?.aborted) return false;
enabled.set(id, shouldEnable); enabled.set(id, shouldEnable);
committed.push({ id, enabled: shouldEnable }); committed.push({ id, enabled: shouldEnable });
@@ -707,15 +1064,20 @@ function fakeStyleManager({ contextMode = null, exitFails = false } = {}) {
/** Cesium viewer double whose flights complete on the next microtask turn. */ /** Cesium viewer double whose flights complete on the next microtask turn. */
function fakeViewer() { function fakeViewer() {
const flights = []; const flights = [];
const views = [];
let cancelled = 0; let cancelled = 0;
return { return {
flights, flights,
views,
get cancelledFlights() { return cancelled; }, get cancelledFlights() { return cancelled; },
camera: { camera: {
flyTo(options) { flyTo(options) {
flights.push(options); flights.push(options);
Promise.resolve().then(() => options.complete?.()); Promise.resolve().then(() => options.complete?.());
}, },
setView(options) {
views.push(options);
},
cancelFlight() { cancelled++; }, cancelFlight() { cancelled++; },
}, },
}; };