mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge branch 'main' into upstream/traffic-layer-stale
This commit is contained in:
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).
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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
@@ -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') {
|
||||||
|
|||||||
@@ -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++; },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user