mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(director): add authored camera directions and anchors
This commit is contained in:
1 parent
0ea10468ea
commit
13c0b8d1bc
24 files changed
+1104
-167
No files matched your search
@@ -0,0 +1,70 @@
|
||||
import { sampleCameraMove } from '../director/camera.js';
|
||||
|
||||
/** Own one authored camera animation and settle it immediately on cancellation or replacement. */
|
||||
export function createCameraMotion({
|
||||
applyPose,
|
||||
now = () => performance.now(),
|
||||
requestFrame = (callback) => requestAnimationFrame(callback),
|
||||
cancelFrame = (handle) => cancelAnimationFrame(handle),
|
||||
} = {}) {
|
||||
let active = null;
|
||||
let disposed = false;
|
||||
const cancel = () => active?.finish(false);
|
||||
return {
|
||||
get active() {
|
||||
return active !== null;
|
||||
},
|
||||
cancel,
|
||||
destroy() {
|
||||
disposed = true;
|
||||
cancel();
|
||||
},
|
||||
play(move, token) {
|
||||
cancel();
|
||||
if (disposed || token?.cancelled || token?.signal?.aborted)
|
||||
return Promise.resolve(false);
|
||||
if (!Number.isFinite(move.durationSec) || move.durationSec <= 0)
|
||||
throw new RangeError('Camera move duration must be positive');
|
||||
return new Promise((resolve, reject) => {
|
||||
const run = { handle: null, finish: null };
|
||||
const startedAt = now();
|
||||
const onAbort = () => run.finish(false);
|
||||
run.finish = (completed, error) => {
|
||||
if (active !== run) return;
|
||||
active = null;
|
||||
if (run.handle !== null) cancelFrame(run.handle);
|
||||
token?.signal?.removeEventListener('abort', onAbort);
|
||||
if (error) reject(error);
|
||||
else resolve(completed);
|
||||
};
|
||||
active = run;
|
||||
token?.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const tick = () => {
|
||||
if (active !== run) return;
|
||||
run.handle = null;
|
||||
if (disposed || token?.cancelled || token?.signal?.aborted) {
|
||||
run.finish(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const progress = Math.min(
|
||||
1,
|
||||
Math.max(0, (now() - startedAt) / (move.durationSec * 1000)),
|
||||
);
|
||||
if (applyPose(sampleCameraMove(move, progress)) === false) {
|
||||
run.finish(false);
|
||||
return;
|
||||
}
|
||||
// Applying a pose can synchronously transfer camera ownership.
|
||||
if (active !== run) return;
|
||||
if (progress >= 1) run.finish(true);
|
||||
else run.handle = requestFrame(tick);
|
||||
} catch (error) {
|
||||
run.finish(false, error);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createCameraMotion } from './cameraMotion.js';
|
||||
const move = {
|
||||
durationSec: 2,
|
||||
easing: 'linear',
|
||||
from: { lat: 0, lon: 0, alt: 100, heading: 0, pitch: 0, roll: 0 },
|
||||
to: { lat: 2, lon: 2, alt: 200, heading: 0, pitch: 0, roll: 0 },
|
||||
};
|
||||
function harness(apply) {
|
||||
let time = 0;
|
||||
let next = 0;
|
||||
const frames = new Map();
|
||||
const poses = [];
|
||||
const owner = createCameraMotion({
|
||||
now: () => time,
|
||||
requestFrame: (cb) => {
|
||||
const id = ++next;
|
||||
frames.set(id, cb);
|
||||
return id;
|
||||
},
|
||||
cancelFrame: (id) => frames.delete(id),
|
||||
applyPose: (pose) => {
|
||||
poses.push(pose);
|
||||
return apply?.(pose);
|
||||
},
|
||||
});
|
||||
return {
|
||||
owner,
|
||||
frames,
|
||||
poses,
|
||||
tick(ms) {
|
||||
time = ms;
|
||||
const callbacks = [...frames.values()];
|
||||
frames.clear();
|
||||
callbacks.forEach((cb) => cb());
|
||||
},
|
||||
};
|
||||
}
|
||||
test('motion uses the authored curve and releases the frame on completion', async () => {
|
||||
const h = harness();
|
||||
const done = h.owner.play(move, { cancelled: false });
|
||||
assert.deepEqual(h.poses[0], move.from);
|
||||
h.tick(1000);
|
||||
assert.equal(h.poses.at(-1).alt, 150);
|
||||
h.tick(2000);
|
||||
assert.equal(await done, true);
|
||||
assert.deepEqual(h.poses.at(-1), move.to);
|
||||
assert.equal(h.frames.size, 0);
|
||||
assert.equal(h.owner.active, false);
|
||||
});
|
||||
test('abort and replacement settle immediately and revoke already queued callbacks', async () => {
|
||||
const h = harness();
|
||||
const abort = new AbortController();
|
||||
const first = h.owner.play(move, { signal: abort.signal });
|
||||
const stale = [...h.frames.values()][0];
|
||||
abort.abort();
|
||||
assert.equal(await first, false);
|
||||
const second = h.owner.play(move, {});
|
||||
const count = h.poses.length;
|
||||
stale();
|
||||
assert.equal(h.poses.length, count);
|
||||
h.owner.destroy();
|
||||
assert.equal(await second, false);
|
||||
assert.equal(h.frames.size, 0);
|
||||
assert.equal(await h.owner.play(move, {}), false);
|
||||
});
|
||||
test('synchronous cancellation from pose application schedules no abandoned frame', async () => {
|
||||
let owner;
|
||||
const h = harness(() => owner.cancel());
|
||||
owner = h.owner;
|
||||
assert.equal(await owner.play(move, {}), false);
|
||||
assert.equal(h.frames.size, 0);
|
||||
});
|
||||
test('failed pose application settles and invalid durations cannot schedule a loop', async () => {
|
||||
const h = harness(() => {
|
||||
throw new Error('camera unavailable');
|
||||
});
|
||||
await assert.rejects(h.owner.play(move, {}), /camera unavailable/);
|
||||
assert.equal(h.frames.size, 0);
|
||||
assert.throws(
|
||||
() => h.owner.play({ ...move, durationSec: 0 }, {}),
|
||||
/duration/,
|
||||
);
|
||||
});
|
||||
+71
-15
@@ -12,6 +12,8 @@
|
||||
* State is persisted to localStorage and can be exported/imported as JSON.
|
||||
*/
|
||||
|
||||
import { resolveCameraPose, resolveCameraMove } from '../director/camera.js';
|
||||
import { createCameraMotion } from './cameraMotion.js';
|
||||
import { createStateChannel } from '../app/stateChannel.js';
|
||||
import { SceneControls } from '../ui/scenes.js';
|
||||
import { buildPlaybackQueue, playSceneQueue } from '../director/playback.js';
|
||||
@@ -84,6 +86,22 @@ export class SceneDirector {
|
||||
this.dataManager = dataManager;
|
||||
this._isMapStackAvailable = isMapStackAvailable;
|
||||
this._scenePacks = scenePacks;
|
||||
this._cameraMotion = createCameraMotion({
|
||||
applyPose: (pose) => this._setCameraView(pose),
|
||||
});
|
||||
const yieldCamera = () => {
|
||||
if (this._usesAuthoredCamera && !this._claimingCamera)
|
||||
this.stopScene('Camera ownership changed');
|
||||
};
|
||||
this._cameraHandoffUnsubscribe =
|
||||
styleManager.subscribeCameraHandoff?.(yieldCamera);
|
||||
const canvas = viewer.scene?.canvas;
|
||||
for (const event of ['pointerdown', 'wheel'])
|
||||
canvas?.addEventListener(event, yieldCamera, { passive: true });
|
||||
this._removeCameraInput = () => {
|
||||
for (const event of ['pointerdown', 'wheel'])
|
||||
canvas?.removeEventListener(event, yieldCamera);
|
||||
};
|
||||
|
||||
/** @type {boolean} True while a scene run is in progress */
|
||||
this._running = false;
|
||||
@@ -179,6 +197,9 @@ export class SceneDirector {
|
||||
this._destroyed = true;
|
||||
this._sceneSeekGeneration++;
|
||||
this._visibilityUnsubscribe?.();
|
||||
this._cameraHandoffUnsubscribe?.();
|
||||
this._removeCameraInput?.();
|
||||
this._cameraMotion?.destroy();
|
||||
this._controls?.destroy();
|
||||
this._state.destroy();
|
||||
this._destroyPromise = Promise.resolve().then(async () => {
|
||||
@@ -903,7 +924,9 @@ export class SceneDirector {
|
||||
const camera = this.styleManager.getCameraState();
|
||||
if (!camera) return;
|
||||
|
||||
shot.camera = camera;
|
||||
shot.camera = shot.move
|
||||
? { ...camera, altitudeReference: 'ellipsoid' }
|
||||
: camera;
|
||||
shot.visual = this.styleManager.getVisualState();
|
||||
shot.layers = this._captureLayerStates();
|
||||
|
||||
@@ -955,11 +978,12 @@ export class SceneDirector {
|
||||
async _loadShot(
|
||||
sceneId,
|
||||
shotId,
|
||||
{ flyDuration = 2.2, fromCamera = null, sceneSeek = null } = {},
|
||||
{ flyDuration = null, fromCamera = null, sceneSeek = null } = {},
|
||||
) {
|
||||
if (this._running) return { started: false, reason: 'already-running' };
|
||||
const { scene, shot } = this._getShot(sceneId, shotId);
|
||||
if (!scene || !shot) return { started: false, reason: 'shot-not-found' };
|
||||
flyDuration ??= shot.move ? shot.durationSec : 2.2;
|
||||
const previousScene =
|
||||
this._project.scenes.find((item) => item.id === this._loadedSceneId) ||
|
||||
null;
|
||||
@@ -972,6 +996,7 @@ export class SceneDirector {
|
||||
// means an in-flight layer transition is cancelled (and rolled back by the
|
||||
// manager) rather than merely ignored once it has already committed.
|
||||
this._cancelActiveSceneTravel();
|
||||
this._usesAuthoredCamera = !!shot.move;
|
||||
this._loadAbort?.abort();
|
||||
const controller = new AbortController();
|
||||
this._loadAbort = controller;
|
||||
@@ -1017,7 +1042,9 @@ export class SceneDirector {
|
||||
return { started: false, reason: 'layers-refused' };
|
||||
}
|
||||
if (seekState) {
|
||||
this._setCameraView(seekState.camera || shot.camera);
|
||||
this._setCameraView(
|
||||
seekState.camera || resolveCameraPose(scene, shot.camera),
|
||||
);
|
||||
this._setProgress(seekState.sceneProgress);
|
||||
this._publishSceneClock(scene, shot, seekState.sceneElapsedSec, {
|
||||
running: false,
|
||||
@@ -1044,10 +1071,10 @@ export class SceneDirector {
|
||||
);
|
||||
const cameraTravel = this._beginShotTravel(scene, shot, flyDuration);
|
||||
try {
|
||||
// _flyCamera calls Cesium's flyTo synchronously before yielding. Publish
|
||||
// the trail phase immediately afterward so its clock overlaps actual
|
||||
// Camera motion starts synchronously before yielding. Publish the
|
||||
// trail phase immediately afterward so its clock overlaps actual
|
||||
// camera motion, never an earlier asynchronous layer-reconcile wait.
|
||||
const flight = this._flyCamera(shot.camera, flyDuration, token);
|
||||
const flight = this._flyShotCamera(scene, shot, flyDuration, token);
|
||||
this._publishShotTravel(scene, shot, cameraTravel);
|
||||
await flight;
|
||||
} catch (error) {
|
||||
@@ -1171,6 +1198,8 @@ export class SceneDirector {
|
||||
|
||||
/** Revoke layer-owned travel motion before a newer load, STOP, or teardown. */
|
||||
_cancelActiveSceneTravel() {
|
||||
this._cameraMotion?.cancel();
|
||||
this._usesAuthoredCamera = false;
|
||||
// The opening locator owns an additional delayed approach/orbit even after
|
||||
// the director's authored flight has settled. Revoke it before cancelling
|
||||
// the camera, whose moveEnd/complete callbacks may already be queued.
|
||||
@@ -1242,7 +1271,9 @@ export class SceneDirector {
|
||||
scene.shots[(shotIndex - 1 + scene.shots.length) % scene.shots.length];
|
||||
const result = await this.loadShot(sceneId, shotId, {
|
||||
flyDuration: shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
|
||||
fromCamera: previousShot?.camera || null,
|
||||
fromCamera: shot.move
|
||||
? null
|
||||
: resolveCameraPose(scene, previousShot?.camera),
|
||||
});
|
||||
return result || { started: false, reason: 'cancelled' };
|
||||
}
|
||||
@@ -1291,7 +1322,8 @@ export class SceneDirector {
|
||||
/** Read timing diagnostics without exposing timer handles or mutable clock state. */
|
||||
getPlaybackTimingState() {
|
||||
return {
|
||||
activeTimers: this._clock.activeTimers,
|
||||
activeTimers:
|
||||
this._clock.activeTimers + (this._cameraMotion?.active ? 1 : 0),
|
||||
snapshot: this._clock.snapshot,
|
||||
};
|
||||
}
|
||||
@@ -1345,7 +1377,9 @@ export class SceneDirector {
|
||||
}
|
||||
}
|
||||
if (!applied) return false;
|
||||
this._setCameraView(seekState.camera || shot.camera);
|
||||
this._setCameraView(
|
||||
seekState.camera || resolveCameraPose(scene, shot.camera),
|
||||
);
|
||||
this._setProgress(seekState.sceneProgress);
|
||||
this._publishSceneClock(scene, shot, seekState.sceneElapsedSec, {
|
||||
running: false,
|
||||
@@ -1419,10 +1453,13 @@ export class SceneDirector {
|
||||
// Older/headless style managers may predate the facade — proceed then.
|
||||
if (typeof this.styleManager?.runImmediateNavigation !== 'function')
|
||||
return true;
|
||||
const claimed = this.styleManager.runImmediateNavigation(
|
||||
'scene',
|
||||
() => true,
|
||||
);
|
||||
let claimed;
|
||||
this._claimingCamera = true;
|
||||
try {
|
||||
claimed = this.styleManager.runImmediateNavigation('scene', () => true);
|
||||
} finally {
|
||||
this._claimingCamera = false;
|
||||
}
|
||||
if (claimed === false) {
|
||||
this._updateStatus('Camera unavailable — exit cockpit first');
|
||||
return false;
|
||||
@@ -1444,7 +1481,7 @@ export class SceneDirector {
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(cameraState.heading || 0),
|
||||
pitch: Cesium.Math.toRadians(cameraState.pitch || -35),
|
||||
pitch: Cesium.Math.toRadians(cameraState.pitch ?? -35),
|
||||
roll: Cesium.Math.toRadians(cameraState.roll || 0),
|
||||
},
|
||||
});
|
||||
@@ -1581,6 +1618,8 @@ export class SceneDirector {
|
||||
this._loadGeneration++;
|
||||
this._clock.stopShot();
|
||||
|
||||
this._usesAuthoredCamera = queue.some(({ shot }) => !!shot.move);
|
||||
|
||||
// Transition to running state
|
||||
this._running = true;
|
||||
this._previewRun = preview;
|
||||
@@ -1957,6 +1996,23 @@ export class SceneDirector {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Use the authored sampler only for explicit moves; ordinary shots keep their existing flights. */
|
||||
async _flyShotCamera(scene, shot, durationSec, token) {
|
||||
const move = resolveCameraMove(scene, shot);
|
||||
if (!move)
|
||||
return this._flyCamera(
|
||||
resolveCameraPose(scene, shot.camera),
|
||||
durationSec,
|
||||
token,
|
||||
);
|
||||
const completed = await this._cameraMotion.play(
|
||||
{ ...move, durationSec },
|
||||
token,
|
||||
);
|
||||
if (!completed && !token.cancelled && !this._destroyed)
|
||||
this.stopScene('Camera move interrupted');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fly the Cesium camera to the given position over the specified duration
|
||||
* using cubic ease-in-out. Resolves when the flight completes, is cancelled,
|
||||
@@ -1998,7 +2054,7 @@ export class SceneDirector {
|
||||
destination,
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(cameraState.heading || 0),
|
||||
pitch: Cesium.Math.toRadians(cameraState.pitch || -35),
|
||||
pitch: Cesium.Math.toRadians(cameraState.pitch ?? -35),
|
||||
roll: Cesium.Math.toRadians(cameraState.roll || 0),
|
||||
},
|
||||
duration,
|
||||
|
||||
@@ -1443,7 +1443,7 @@ test('unsupported stored documents cannot be overwritten by fallback edits', asy
|
||||
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
|
||||
await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) });
|
||||
assert.equal(director._storageReadError, null);
|
||||
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 3);
|
||||
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 4);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
@@ -1491,3 +1491,29 @@ test('invalid authored edits cannot persist an unreadable project over a good sa
|
||||
assert.match(notice, /camera.lat/);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('zero camera pitch is preserved by both immediate placement and ordinary flight', async () => {
|
||||
const { director, viewer, restore } = makeDirector();
|
||||
try {
|
||||
let placed;
|
||||
viewer.camera.setView = (options) => { placed = options; };
|
||||
const pose = { lat: 10, lon: 20, alt: 500, heading: 0, pitch: 0, roll: 0 };
|
||||
director._setCameraView(pose);
|
||||
assert.equal(placed.orientation.pitch, 0);
|
||||
await director._flyCamera(pose, 0.2, { cancelled: false });
|
||||
assert.equal(viewer.flights.at(-1).orientation.pitch, 0);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('camera refusal starts no authored frame or playback clock', async () => {
|
||||
const { director, styleManager, viewer, restore } = makeDirector();
|
||||
try {
|
||||
const shot = director._project.scenes[0].shots[0];
|
||||
shot.move = { from: { ...shot.camera, altitudeReference: 'ellipsoid' }, easing: 'linear' };
|
||||
styleManager.runImmediateNavigation = () => false;
|
||||
assert.equal((await director.startScene('scene-1', { single: true })).reason, 'camera-unavailable');
|
||||
assert.equal(director._cameraMotion.active, false);
|
||||
assert.equal(director.getPlaybackTimingState().activeTimers, 0);
|
||||
assert.deepEqual(viewer.flights, []);
|
||||
} finally { restore(); }
|
||||
});
|
||||
@@ -40,7 +40,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
const duration = shot.durationSec || defaultShotDurationSec;
|
||||
timing = director._startSceneClockTicker(scene, shot, token);
|
||||
travel = director._beginShotTravel(scene, shot, duration);
|
||||
const flight = director._flyCamera(shot.camera, duration, token);
|
||||
const flight = director._flyShotCamera(scene, shot, duration, token);
|
||||
director._publishShotTravel(scene, shot, travel);
|
||||
return flight;
|
||||
},
|
||||
|
||||
+26
-10
@@ -67,11 +67,11 @@ function normalizeBloomState(
|
||||
{ projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {},
|
||||
) {
|
||||
// Determine which bloom scale the stored value was encoded under.
|
||||
// Older projects (version < PROJECT_VERSION) used scale version 1.
|
||||
// Older projects (version < 3) used scale version 1.
|
||||
const explicitVersion = Number(rawBloom.version);
|
||||
const bloomVersion = Number.isFinite(explicitVersion)
|
||||
? explicitVersion
|
||||
: projectVersion >= PROJECT_VERSION
|
||||
: projectVersion >= 3
|
||||
? BLOOM_SCALE_VERSION
|
||||
: 1;
|
||||
|
||||
@@ -231,14 +231,29 @@ export function normalizeShot(
|
||||
Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC,
|
||||
),
|
||||
holdSec: Math.max(0, Number(rawShot?.holdSec) || 0),
|
||||
camera: {
|
||||
lat: Number(camera.lat) || 0,
|
||||
lon: Number(camera.lon) || 0,
|
||||
alt: Number.isFinite(Number(camera.alt)) ? Number(camera.alt) : 800,
|
||||
heading: Number(camera.heading) || 0,
|
||||
pitch: Number.isFinite(Number(camera.pitch)) ? Number(camera.pitch) : -35,
|
||||
roll: Number(camera.roll) || 0,
|
||||
},
|
||||
camera: camera.anchorId
|
||||
? {
|
||||
anchorId: camera.anchorId,
|
||||
heading: Number(camera.heading) || 0,
|
||||
pitch: Number.isFinite(Number(camera.pitch))
|
||||
? Number(camera.pitch)
|
||||
: -35,
|
||||
roll: Number(camera.roll) || 0,
|
||||
}
|
||||
: {
|
||||
lat: Number(camera.lat) || 0,
|
||||
lon: Number(camera.lon) || 0,
|
||||
alt: Number.isFinite(Number(camera.alt)) ? Number(camera.alt) : 800,
|
||||
heading: Number(camera.heading) || 0,
|
||||
pitch: Number.isFinite(Number(camera.pitch))
|
||||
? Number(camera.pitch)
|
||||
: -35,
|
||||
roll: Number(camera.roll) || 0,
|
||||
...(camera.altitudeReference || rawShot?.move
|
||||
? { altitudeReference: camera.altitudeReference || 'ellipsoid' }
|
||||
: {}),
|
||||
},
|
||||
...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}),
|
||||
visual: {
|
||||
style: visual.style || 'normal',
|
||||
bloom: normalizeBloomState(bloom, {
|
||||
@@ -331,6 +346,7 @@ export function normalizeProject(rawProject) {
|
||||
return {
|
||||
id: scene?.id || uid('scene'),
|
||||
title: scene?.title || `Scene ${sceneIdx + 1}`,
|
||||
...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}),
|
||||
releaseLayerIds: [
|
||||
...new Set(
|
||||
(Array.isArray(scene?.releaseLayerIds)
|
||||
|
||||
Reference in new issue
Block a user