feat(director): add authored camera directions and anchors

This commit is contained in:
Sameh Khamis committed 2026-09-15 16:53:26 -07:00
1 parent 0ea10468ea
commit 13c0b8d1bc
24 files changed
+1104 -167

No files matched your search

+5
View File
@@ -1,5 +1,10 @@
# Changelog
- Director version 4 adds named camera anchors and explicit pose-to-pose moves
with shared playback/seek interpolation, easing and holds. Navigation and
manual input cancel authored motion; older scene files retain existing flights.
- Director validates bounded version-3 scene files before replacing a project,
preserves unreadable browser saves, migrates legacy bloom once and preserves
zero-pitch/low-altitude camera and scope/detection edits. Project normalization has a separate owner.
+8 -1
View File
@@ -1,5 +1,12 @@
# God's Eye View Current State
Director version-4 documents add named geographic anchors and optional explicit
camera moves with easing, duration and holds. Playback and seek share one
coordinate sampler; navigation/manual input revokes authored motion and pending
holds. Anchor heights and explicit endpoints require the WGS84 ellipsoid
reference. Legacy projects retain their ordinary flights, content and edits.
See [camera directions](DIRECTOR-CAMERA.md).
Realtime voice composes separate connection, response/tool, Radio handoff, input
and audio-meter, cost, viewport-context and diagnostic owners. The existing
controller exports and session/backend contracts remain available. Tool protocol,
@@ -27,7 +34,7 @@ holds; stale tick callbacks cannot publish into a replacement. Registered pack
rules own presentation overrides and Nepal map fallback without changing saved
shots or content. Scene-document validation and legacy migration now have separate
owners. Invalid imports preserve the current project; unreadable saved projects
are protected from fallback writes. Version 3 remains the export format, with
are protected from fallback writes. Version 4 is the export format, with
zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md);
data packs and interactions remain planned in [Director](DIRECTOR.md).
+109
View File
@@ -0,0 +1,109 @@
# Director camera directions
Version 4 scene documents can name geographic anchors and describe an explicit
camera move followed by a hold. Existing camera-pose shots still use the ordinary
camera flight. Import, playback, Load, Replay and seek accept the new directions;
a visual anchor/path editor is a later authoring step.
```json
{
"version": 4,
"scenes": [{
"id": "austin",
"title": "Across Austin",
"anchors": [
{ "id": "west", "title": "West viewpoint", "lat": 30.2672,
"lon": -97.76, "alt": 6000, "altitudeReference": "ellipsoid" },
{ "id": "east", "title": "East viewpoint", "lat": 30.2672,
"lon": -97.73, "alt": 7000, "altitudeReference": "ellipsoid" }
],
"shots": [{
"id": "crossing",
"title": "Cross the city",
"durationSec": 4,
"holdSec": 2,
"camera": { "anchorId": "east", "heading": 10, "pitch": -45, "roll": 0 },
"move": {
"from": { "anchorId": "west", "heading": 350, "pitch": -45, "roll": 0 },
"easing": "cubic-in-out"
},
"visual": { "style": "normal" },
"layers": {}
}]
}]
}
```
## One owner for each authored value
An anchor is a scene-local geographic position, with a stable `id`, optional
`title`, latitude, longitude, height and explicit altitude reference. Each scene
accepts at most 1,024 anchors. IDs must be unique within that scene. References to
missing anchors fail validation before replacing a project.
The shot's `camera` is its destination. It can be an inline pose or an
`anchorId` plus orientation. The optional `move.from` uses the same pose/reference
shape. References cannot also carry inline coordinates. They do not form chains.
Heading, pitch and roll belong to each camera pose, so two shots can look in
different directions from the same geographic anchor. Omitted orientation uses
heading 0, pitch −35 and roll 0 degrees.
An explicit move requires `move.from`, `move.easing`, `durationSec` and `holdSec`.
Its duration is 0.2–86,400 seconds; the hold is 0–86,400 seconds. The destination
is not copied into another field. `linear` and `cubic-in-out` are the supported
easings. A shot without `move` retains the existing ordinary flight, even in a
version-4 file. Its destination may also reference an anchor.
Capture continues to create ordinary camera-pose shots. Update Shot stores the
current viewport as an inline destination, preserving an explicit move's start
and easing; it does not change a shared anchor and thereby move other shots.
Editing imported directions and anchors currently means editing the scene JSON.
## Coordinates and altitude
Latitude/longitude use WGS84 degrees. Orientation uses the application's Cesium
heading/pitch/roll convention in degrees: heading rotates from local north,
negative pitch looks down, and zero pitch is horizontal. Camera positions use
meters above the WGS84 ellipsoid, matching `Cartesian3.fromDegrees` and captured
camera poses. These heights are not mean-sea-level elevations or heights above
the visible surface.
Every anchor and every inline endpoint of an explicit move must say
`"altitudeReference": "ellipsoid"`. Ordinary legacy poses without that field
already use the same reference. Terrain-relative and mean-sea-level references
are rejected; no terrain request, default ground estimate or implicit conversion
is made. Authors must provide ellipsoidal heights with enough clearance for their
scene. The format does not promise collision avoidance or terrain following.
During an explicit move, latitude and altitude interpolate between endpoints.
Longitude takes the shortest signed arc across the dateline; heading and roll
also take their shortest signed arcs. An exact 180-degree tie takes the negative
arc. This is an authored coordinate interpolation, not a geodesic or a camera
flight with an automatically raised arc. Endpoints are exact, including zero
pitch. Curved paths and look-at targets are later extensions.
## Time, seeking and cancellation
Load and Replay of an explicit move start at its authored `from` pose, regardless
of the current viewport. Play uses that same start. Both live playback and seek
use `sampleCameraMove`; a seek into the hold uses the exact destination. Backward
seeks do not depend on a prior camera position. Existing scene-pack media/reveal
rules can extend holds as before.
Each active explicit move owns one animation-frame callback. Stop, replacement,
import, teardown, a navigation handoff or manual pointer/wheel input revokes that
callback and settles the move. Navigation also cancels a run during an authored
hold, so the next shot cannot retake the camera. Camera claims still respect
Cockpit's refusal. Already queued callbacks cannot move a replacement scene.
The timing diagnostic includes the active authored camera animation.
The renderer-independent `gods-eye-view/director` export provides
`resolveCameraPose(scene, camera)`, `resolveCameraMove(scene, shot)` and
`sampleCameraMove(move, progress)`. These expect validated/normalized data.
Rendering and frame ownership live in `src/scenes/cameraMotion.js`; the existing
scene controller supplies camera application and lifecycle cancellation.
Version-1/2/3 projects migrate to version 4 without converting their ordinary
shots into explicit moves. IDs, positions, durations, visual settings, packs and
source attribution survive. The bloom scale remains independent of the document
version; version-3 bloom is never interpreted as the older inverted scale.
+7 -6
View File
@@ -67,16 +67,17 @@ and river coordinates are bundled. See the
existing recipes and content. Tests cover direct load, forward/backward seek,
replay, bounded media waits and cancellation at pending transitions.
2. **Versioned scene document — implemented for existing camera-pose shots.**
[Version 3](SCENE-DOCUMENT.md) remains the write format. Bounded validation
[The scene document](SCENE-DOCUMENT.md) preserves version-1/2/3 imports. Bounded validation
rejects malformed files before replacing current state; legacy migration
preserves IDs, pack bindings and visual edits. Unreadable saved files are
protected from fallback writes. Camera anchors, pack manifests and actions
receive stable IDs with their respective later format extensions.
3. **Camera directions.** Define named geographic anchors and explicit
move/hold segments: start and destination, duration, easing, heading, pitch,
roll and stop duration. State coordinate units, altitude reference and
terrain-relative behavior. Support the current camera-pose shots first;
paths, look-at targets and pauses follow with deterministic seek behavior.
3. **Camera directions — implemented for pose-to-pose moves.**
[Version 4](DIRECTOR-CAMERA.md) adds scene-local geographic anchors, explicit
start/destination poses, easing, duration and holds. Playback and seek share
the same sampler. Ellipsoidal heights are explicit; terrain-relative input
is rejected. Existing shots retain ordinary flights. Curved paths, look-at
targets and visual authoring are later extensions.
4. **Data packs and placement.** Define a pack manifest with format/version,
source, attribution/license and optional integrity/size metadata. Distinguish
where bytes live (relative files, a URL, or an eventual bundle) from where
+14 -15
View File
@@ -1,13 +1,13 @@
# Scene document version 3
# Scene document format
Scenes exports a UTF-8 JSON project. Version 3 remains the write format; existing
version-3 exports need no conversion. Versions 1, 2 and unversioned legacy files
are migrated on import. Unknown versions or unsupported fields are rejected with
Scenes exports a UTF-8 JSON project. Version 4 adds [camera directions](DIRECTOR-CAMERA.md)
and is the write format. Versions 1, 2, 3 and unversioned legacy files migrate on
import without converting their existing shots into authored moves. Unknown versions or unsupported fields are rejected with
a field path before the current project, selection or saved bytes change.
```json
{
"version": 3,
"version": 4,
"scenes": [{
"id": "my-scene",
"title": "My scene",
@@ -30,21 +30,21 @@ a field path before the current project, selection or saved bytes change.
The root requires `scenes` (an array, which may be empty). Optional root fields
are `version`, `createdAt`, `updatedAt` and `installedBuiltInSceneIds` (string IDs).
Each scene requires a `shots` array; optional fields are `id`, `title`,
`releaseLayerIds` and `appliedShotPacks`. A pack marker has `id`, optional numeric
`releaseLayerIds`, `appliedShotPacks` and version-4 `anchors`. A pack marker has `id`, optional numeric
`version` and `shotBindings` (original shot title to saved shot ID). These markers
retain the editor's existing pack-update and rename behavior.
Shots accept `id`, `title`, `durationSec`, `holdSec`, `camera`, `visual`, `layers`,
`sourcePackId` and `sourcePackVersion`. Scene IDs are unique within the project;
`sourcePackId`, `sourcePackVersion` and version-4 `move`. Scene IDs are unique within the project;
shot IDs are unique within their scene. Existing IDs and pack bindings survive
migration. Missing IDs are generated once and persisted by the next save/export.
Missing titles and optional camera/visual fields receive the editor's defaults.
Empty scenes and intentionally empty projects remain empty.
Camera latitude/longitude and heading/pitch/roll use degrees. Altitude uses the
existing camera-pose height in meters. Import preserves finite heights below
100 meters and zero pitch, which previously fell through to defaults. Explicit
altitude-reference and terrain-relative directions are a subsequent format step.
Camera latitude/longitude and heading/pitch/roll use degrees. Altitude uses
meters above the WGS84 ellipsoid. Import preserves finite heights below
100 meters and zero pitch, which previously fell through to defaults. Anchors and explicit moves require the `ellipsoid` altitude reference;
terrain-relative inputs are rejected. See the camera contract for exact semantics.
Flight durations retain the editor's minimum of 0.2 seconds and its 4-second
fallback for zero; holds may be zero.
@@ -95,8 +95,7 @@ can preserve temporary edits. Storage writes use the same validation before repl
edits and storage write failures surface an unsaved toast.
Only authored project fields belong in this file. Runtime clocks, camera flights,
loading progress and run diagnostics remain separate. Camera anchors, asset-pack
manifests and declarative interactions are planned extensions, not accepted fields
in version 3. They will receive stable IDs and their own validated migration when
introduced. No scene assets, source links, licenses or contributor attribution
loading progress and run diagnostics remain separate. Version 4 supports scene-local camera anchors and authored moves. Asset-pack
manifests and declarative interactions remain planned extensions. They will
receive stable IDs and their own validated migration when introduced. No scene assets, source links, licenses or contributor attribution
change with this document boundary.
+4 -1
View File
@@ -733,5 +733,8 @@
"src/director/timeline.test.mjs",
"src/scenes/packs/registry.test.mjs",
"scripts/qa-director-timing.mjs",
"src/director/document.test.mjs"
"src/director/document.test.mjs",
"src/director/camera.test.mjs",
"src/scenes/cameraMotion.test.mjs",
"scripts/qa-director-camera.mjs"
]
+19 -4
View File
@@ -618,7 +618,10 @@
"src/director/playback.js",
"src/director/clock.js",
"src/director/timeline.js",
"src/director/document.js"
"src/director/document.js",
"src/director/documentFields.js",
"src/director/cameraDocument.js",
"src/director/camera.js"
],
"external": []
},
@@ -648,7 +651,11 @@
"src/scenes/packs/defaults.js",
"src/scenes/packs/nepal.js",
"src/director/document.js",
"src/scenes/project.js"
"src/scenes/project.js",
"src/director/documentFields.js",
"src/director/cameraDocument.js",
"src/director/camera.js",
"src/scenes/cameraMotion.js"
],
"external": ["cesium"]
},
@@ -1755,7 +1762,11 @@
"src/scenes/packs/defaults.js",
"src/scenes/packs/nepal.js",
"src/director/document.js",
"src/scenes/project.js"
"src/scenes/project.js",
"src/director/documentFields.js",
"src/director/cameraDocument.js",
"src/director/camera.js",
"src/scenes/cameraMotion.js"
],
"external": [
"@mapbox/vector-tile",
@@ -2220,7 +2231,11 @@
},
"ui-navigation": {
"exports": ["./ui/navigation"],
"modules": ["src/navigationPolicy.js", "src/ui/navigationController.js"],
"modules": [
"src/navigationPolicy.js",
"src/ui/navigationController.js",
"src/app/stateChannel.js"
],
"external": ["cesium"]
},
"ui-share-restoration": {
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env node
/** Verify imported camera directions through real rendering and camera authority handoffs. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const output = path.resolve('qa-shots/director-camera');
fs.mkdirSync(output, { recursive: true });
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failed = 0;
function check(name, passed) {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) failed++;
}
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
const result = await page.evaluate(async () => {
const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
const file = {
version: 4,
scenes: [
{
id: 'directions',
title: 'Camera directions',
anchors: [
{
id: 'start',
title: 'Austin west',
lat: 30.2672,
lon: -97.76,
alt: 6000,
altitudeReference: 'ellipsoid',
},
{
id: 'end',
title: 'Austin east',
lat: 30.2672,
lon: -97.73,
alt: 7000,
altitudeReference: 'ellipsoid',
},
],
shots: [
{
id: 'move',
title: 'Across Austin',
durationSec: 1,
holdSec: 0.2,
camera: { anchorId: 'end', heading: 10, pitch: -45, roll: 0 },
move: {
from: { anchorId: 'start', heading: 350, pitch: -45, roll: 0 },
easing: 'linear',
},
layers: {},
visual: { style: 'normal' },
},
],
},
],
};
await d.importProjectFile(
new File([JSON.stringify(file)], 'directions.json'),
);
const scene = d._project.scenes[0];
const shot = scene.shots[0];
const imported =
scene.anchors.length === 2 && shot.camera.anchorId === 'end';
await d.seekScene(scene.id, 0.5 / 1.2);
const middle = style.getCameraState();
const seekMidpoint =
Math.abs(middle.lon - -97.745) < 1e-6 &&
Math.abs(middle.alt - 6500) < 0.01;
await d.seekScene(scene.id, 0);
const back = style.getCameraState();
const backward = Math.abs(back.lon - -97.76) < 1e-6;
const replay = await d.replayShot(scene.id, shot.id);
const end = style.getCameraState();
const replayEnd =
replay?.started &&
Math.abs(end.lon - -97.73) < 1e-6 &&
Math.abs(end.alt - 7000) < 0.01;
d.stopScene();
await d.startScene(scene.id, { single: true, preview: false });
const completed =
!d.running && d.getPlaybackTimingState().activeTimers === 0;
const settled = Math.abs(style.getCameraState().lon - -97.73) < 1e-6;
// Updating the target creates an explicit inline pose while retaining the authored start.
d.updateSelectedShot();
const saved = JSON.parse(
localStorage.getItem('godsEyeView.sceneProject.v2'),
);
const updated =
saved.scenes[0].shots[0].camera.altitudeReference === 'ellipsoid' &&
saved.scenes[0].shots[0].move.from.anchorId === 'start';
shot.durationSec = 30;
const run = d.startScene(scene.id, { single: true, preview: false });
const deadline = Date.now() + 10000;
while (!d._cameraMotion.active && Date.now() < deadline)
await new Promise((r) => setTimeout(r, 20));
const started = d._cameraMotion.active;
style.runImmediateNavigation('location', () => true);
await run;
const revoked =
started &&
!d.running &&
!d._cameraMotion.active &&
d.getPlaybackTimingState().activeTimers === 0;
const stoppedPose = style.getCameraState();
await new Promise((r) => setTimeout(r, 150));
const stable =
Math.abs(style.getCameraState().lon - stoppedPose.lon) < 1e-7;
return {
imported,
seekMidpoint,
backward,
replayEnd,
completed,
settled,
updated,
revoked,
stable,
};
});
for (const [name, passed] of Object.entries(result)) check(name, passed);
await new Promise((r) => setTimeout(r, 6000));
await page.screenshot({ path: path.join(output, 'austin.png') });
await page.evaluate(() => {
const d = window.__godsEyeView.sceneDirector;
const pose = d.styleManager.getCameraState();
d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 });
d.viewer.scene.requestRender();
});
await new Promise((r) => setTimeout(r, 3000));
await page.screenshot({ path: path.join(output, 'angle.png') });
const inputStarted = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const scene = d._project.scenes[0];
window.__qaCameraRun = d.startScene(scene.id, {
single: true,
preview: false,
});
const deadline = Date.now() + 10000;
while (!d._cameraMotion.active && Date.now() < deadline)
await new Promise((r) => setTimeout(r, 20));
return d._cameraMotion.active;
});
const canvas =
(await page.$('canvas.cesium-widget-canvas')) ||
(await page.$('.cesium-widget canvas'));
const rect = await canvas.boundingBox();
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
await page.mouse.down();
await page.mouse.up();
const input =
inputStarted &&
(await page.evaluate(async () => {
await window.__qaCameraRun;
const d = window.__godsEyeView.sceneDirector;
return !d.running && d.getPlaybackTimingState().activeTimers === 0;
}));
check('manual canvas input revokes the move and releases clocks', input);
const hold = await page.evaluate(async () => {
const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
const scene = d._project.scenes[0];
const shot = scene.shots[0];
shot.durationSec = 0.2;
shot.holdSec = 30;
const run = d.startScene(scene.id, { single: true, preview: false });
const deadline = Date.now() + 10000;
while (
(!d._activeSceneTravel || !d.running || d._cameraMotion.active) &&
Date.now() < deadline
)
await new Promise((r) => setTimeout(r, 20));
const holding = d.running && !d._cameraMotion.active;
const began = performance.now();
style.runImmediateNavigation('location', () => true);
await run;
return (
holding &&
performance.now() - began < 1000 &&
d.getPlaybackTimingState().activeTimers === 0
);
});
check('navigation during an authored hold settles immediately', hold);
if (errors.length)
console.error(
errors.map((message) => message.replace(/https?:\/\/\S+/g, '[URL]')),
);
check('no uncaught browser errors', errors.length === 0);
} finally {
await browser.close();
}
if (failed) process.exitCode = 1;
+54
View File
@@ -0,0 +1,54 @@
/** Portable camera poses and authored motion. Angles are degrees; heights are ellipsoidal meters. */
/** Resolve an inline pose or a scene-local geographic anchor without modifying authored state. */
export function resolveCameraPose(scene, camera) {
if (!camera) return null;
const position = camera.anchorId
? scene?.anchors?.find(({ id }) => id === camera.anchorId)
: camera;
if (!position) throw new Error('Camera anchor is unavailable');
return {
lat: position.lat,
lon: position.lon,
alt: position.alt,
heading: camera.heading ?? 0,
pitch: camera.pitch ?? -35,
roll: camera.roll ?? 0,
};
}
/** Resolve both authored endpoints. An explicit move never depends on the live viewport. */
export function resolveCameraMove(scene, shot) {
if (!shot?.move) return null;
return {
from: resolveCameraPose(scene, shot.move.from),
to: resolveCameraPose(scene, shot.camera),
easing: shot.move.easing,
durationSec: shot.durationSec,
};
}
/** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */
export function sampleCameraMove(move, progress) {
const t = Math.max(0, Math.min(1, Number(progress) || 0));
if (t === 0) return { ...move.from };
if (t === 1) return { ...move.to };
const eased =
move.easing === 'linear'
? t
: t < 0.5
? 4 * t ** 3
: 1 - (-2 * t + 2) ** 3 / 2;
const lerp = (a, b) => a + (b - a) * eased;
const angle = (a, b) =>
a + (((((b - a + 540) % 360) + 360) % 360) - 180) * eased;
const { from, to } = move;
return {
lat: lerp(from.lat, to.lat),
lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180,
alt: lerp(from.alt, to.alt),
heading: angle(from.heading, to.heading),
pitch: lerp(from.pitch, to.pitch),
roll: angle(from.roll, to.roll),
};
}
+153
View File
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseSceneDocument, stringifySceneDocument } from './document.js';
import { normalizeProject } from '../scenes/project.js';
import { resolveCameraMove, sampleCameraMove } from './camera.js';
import { sceneSeekState } from './timeline.js';
const fixture = () => ({
version: 4,
scenes: [
{
id: 'scene',
title: 'Cross the dateline',
anchors: [
{
id: 'west',
title: 'Start',
lat: 10,
lon: 179,
alt: 400,
altitudeReference: 'ellipsoid',
},
{
id: 'east',
title: 'End',
lat: 12,
lon: -179,
alt: 800,
altitudeReference: 'ellipsoid',
},
],
shots: [
{
id: 'move',
title: 'Cross',
durationSec: 4,
holdSec: 2,
camera: { anchorId: 'east', heading: 10, pitch: 0, roll: 0 },
move: {
from: { anchorId: 'west', heading: 350, pitch: -40, roll: 0 },
easing: 'linear',
},
},
],
},
],
});
test('version 4 retains anchor identity, references and explicit move edits through normalization', () => {
const raw = fixture();
const project = normalizeProject(parseSceneDocument(JSON.stringify(raw)));
assert.deepEqual(project.scenes[0].anchors, raw.scenes[0].anchors);
assert.deepEqual(
project.scenes[0].shots[0].move,
raw.scenes[0].shots[0].move,
);
assert.deepEqual(
project.scenes[0].shots[0].camera,
raw.scenes[0].shots[0].camera,
);
assert.deepEqual(
normalizeProject(parseSceneDocument(stringifySceneDocument(project))),
project,
);
});
test('v3 unversioned bloom already uses scale 2 and is not migrated again in v4', () => {
const project = normalizeProject(
parseSceneDocument(
JSON.stringify({
version: 3,
scenes: [
{ shots: [{ visual: { bloom: { enabled: true, intensity: 50 } } }] },
],
}),
),
);
assert.equal(project.version, 4);
assert.equal(project.scenes[0].shots[0].visual.bloom.intensity, 50);
});
test('move endpoints, easing, shortest arcs and hold agree with scene seeking', () => {
const scene = normalizeProject(parseSceneDocument(JSON.stringify(fixture())))
.scenes[0];
const move = resolveCameraMove(scene, scene.shots[0]);
const midpoint = sampleCameraMove(move, 0.5);
assert.equal(midpoint.lon, -180);
assert.equal(midpoint.heading, 360);
assert.equal(midpoint.lat, 11);
assert.equal(midpoint.alt, 600);
assert.equal(midpoint.pitch, -20);
const seek = (p) =>
sceneSeekState(
scene,
p,
(_, shot) => shot.durationSec + shot.holdSec,
(_, shot) => shot.holdSec,
);
assert.deepEqual(seek(1 / 3).camera, midpoint);
assert.deepEqual(seek(0).camera, move.from);
assert.deepEqual(seek(5 / 6).camera, move.to);
assert.equal(seek(5 / 6).holdElapsedSec, 1);
assert.deepEqual(
seek(1 / 3).camera,
midpoint,
'backward seek is independent of previous viewport state',
);
assert.equal(
sampleCameraMove({ ...move, easing: 'cubic-in-out' }, 0.25).lat,
10.125,
);
assert.deepEqual(sampleCameraMove(move, 1), move.to);
});
test('unknown anchors, duplicate IDs, mixed references, bad easing and unspecified altitude are rejected', () => {
for (const mutate of [
(s) => {
s.shots[0].camera.anchorId = 'missing';
},
(s) => {
s.anchors[1].id = 'west';
},
(s) => {
s.anchors[0].altitudeReference = 'terrain';
},
(s) => {
delete s.anchors[0].altitudeReference;
},
(s) => {
s.shots[0].camera.lon = 1;
},
(s) => {
s.shots[0].move.easing = 'bounce';
},
(s) => {
s.shots[0].move.from = { lat: 1, lon: 2, alt: 3 };
},
(s) => {
s.shots[0].durationSec = 0;
},
(s) => {
delete s.shots[0].holdSec;
},
]) {
const project = fixture();
mutate(project.scenes[0]);
assert.throws(() => parseSceneDocument(JSON.stringify(project)), {
name: 'SceneDocumentError',
});
}
const old = fixture();
old.version = 3;
assert.throws(
() => parseSceneDocument(JSON.stringify(old)),
/unsupported field/,
);
});
+88
View File
@@ -0,0 +1,88 @@
import {
fields,
string,
number,
optional,
array,
fail,
uniqueId,
} from './documentFields.js';
const POSITION = { lat: [-90, 90], lon: [-180, 180], alt: [-12000, 1e9] };
const ORIENTATION = {
heading: [-360, 360],
pitch: [-90, 90],
roll: [-360, 360],
};
function coordinates(value, path, bounds, required, legacy) {
for (const [key, range] of Object.entries(bounds)) {
if (required || Object.hasOwn(value, key))
number(value[key], `${path}.${key}`, ...range, legacy);
}
}
function reference(value, path) {
if (value !== 'ellipsoid')
fail(
path,
'expected ellipsoid height in meters; terrain-relative heights are not supported',
);
}
function pose(value, path, version, anchorIds, explicit = false) {
const anchored = version >= 4 && Object.hasOwn(value || {}, 'anchorId');
fields(value, path, [
...Object.keys(ORIENTATION),
...(anchored
? ['anchorId']
: [
...Object.keys(POSITION),
...(version >= 4 ? ['altitudeReference'] : []),
]),
]);
coordinates(value, path, ORIENTATION, false, version < 3);
if (anchored) {
string(value.anchorId, `${path}.anchorId`);
if (!anchorIds.has(value.anchorId))
fail(`${path}.anchorId`, 'unknown scene anchor');
} else {
coordinates(value, path, POSITION, explicit, version < 3);
if (explicit || Object.hasOwn(value, 'altitudeReference'))
reference(value.altitudeReference, `${path}.altitudeReference`);
}
}
/** Validate scene-local anchor IDs, camera references and explicit motion without reading terrain or applying state. */
export function validateSceneCameras(scene, path, version) {
const anchorIds = new Set();
optional(scene, 'anchors', path, (anchors, field) => {
array(anchors, field, 1024);
anchors.forEach((anchor, i) => {
const at = `${field}[${i}]`;
fields(anchor, at, [
'id',
'title',
...Object.keys(POSITION),
'altitudeReference',
]);
string(anchor.id, `${at}.id`);
uniqueId(anchor, at, anchorIds);
optional(anchor, 'title', at, (v, p) => string(v, p, 4096));
coordinates(anchor, at, POSITION, true, false);
reference(anchor.altitudeReference, `${at}.altitudeReference`);
});
});
scene.shots.forEach((shot, index) => {
const at = `${path}.shots[${index}]`;
optional(shot, 'move', at, (move, field) => {
fields(move, field, ['from', 'easing']);
if (!['linear', 'cubic-in-out'].includes(move.easing))
fail(`${field}.easing`, 'expected linear or cubic-in-out');
pose(move.from, `${field}.from`, version, anchorIds, true);
pose(shot.camera, `${at}.camera`, version, anchorIds, true);
number(shot.durationSec, `${at}.durationSec`, 0.2, 86400, false);
number(shot.holdSec, `${at}.holdSec`, 0, 86400, false);
});
optional(shot, 'camera', at, (camera, field) =>
pose(camera, field, version, anchorIds),
);
});
}
+21 -110
View File
@@ -1,98 +1,21 @@
/** Bounded, renderer-independent validation for authored scene documents. */
export const SCENE_DOCUMENT_VERSION = 3;
export const SCENE_DOCUMENT_LIMITS = Object.freeze({
bytes: 5 * 1024 * 1024,
scenes: 256,
shots: 10000,
collection: 10000,
depth: 24,
nodes: 200000,
string: 65536,
});
import {
SCENE_DOCUMENT_LIMITS,
fail,
object,
fields,
string,
number,
optional,
array,
ids,
uniqueId,
jsonTree,
} from './documentFields.js';
import { validateSceneCameras } from './cameraDocument.js';
export { SCENE_DOCUMENT_LIMITS, SceneDocumentError } from './documentFields.js';
export const SCENE_DOCUMENT_VERSION = 4;
/** A project error identifies a field without echoing its supplied value. */
export class SceneDocumentError extends Error {
constructor(path, reason) {
super(`${path}: ${reason}`);
this.name = 'SceneDocumentError';
this.path = path;
}
}
const fail = (path, reason) => {
throw new SceneDocumentError(path, reason);
};
const object = (value, path) => {
if (!value || typeof value !== 'object' || Array.isArray(value))
fail(path, 'expected an object');
};
function fields(value, path, allowed) {
object(value, path);
for (const key of Object.keys(value)) {
if (!allowed.includes(key)) fail(`${path}.${key}`, 'unsupported field');
}
}
function string(value, path, max = 256) {
if (typeof value !== 'string' || !value.trim() || value.length > max)
fail(path, `expected nonempty text, at most ${max} characters`);
}
function number(value, path, min, max, legacy) {
const numeric =
legacy && typeof value === 'string' && value.trim() ? Number(value) : value;
if (
typeof numeric !== 'number' ||
!Number.isFinite(numeric) ||
numeric < min ||
numeric > max
)
fail(path, `expected a number from ${min} to ${max}`);
}
function optional(value, key, path, check) {
if (Object.hasOwn(value, key)) check(value[key], `${path}.${key}`);
}
function array(value, path, max = SCENE_DOCUMENT_LIMITS.collection) {
if (!Array.isArray(value) || value.length > max)
fail(path, `expected an array of at most ${max} entries`);
}
function ids(value, path) {
array(value, path);
value.forEach((id, index) => string(id, `${path}[${index}]`));
}
function uniqueId(value, path, seen) {
if (!Object.hasOwn(value, 'id')) return; // Legacy omissions receive an ID once during migration.
string(value.id, `${path}.id`);
if (seen.has(value.id)) fail(`${path}.id`, 'duplicate ID');
seen.add(value.id);
}
function jsonTree(value, path, budget, depth = 0) {
if (
++budget.nodes > SCENE_DOCUMENT_LIMITS.nodes ||
depth > SCENE_DOCUMENT_LIMITS.depth
)
fail(path, 'project complexity limit exceeded');
if (value === null || typeof value === 'boolean') return;
if (typeof value === 'string') {
if (value.length > SCENE_DOCUMENT_LIMITS.string)
fail(path, 'text is too long');
return;
}
if (typeof value === 'number' && Number.isFinite(value)) return;
if (!value || typeof value !== 'object') fail(path, 'expected a JSON value');
if (
Object.getPrototypeOf(value) !== Object.prototype &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) !== null
)
fail(path, 'expected a JSON object');
const entries = Object.entries(value);
if (entries.length > SCENE_DOCUMENT_LIMITS.collection)
fail(path, 'too many entries');
for (const [key, child] of entries) {
if (['__proto__', 'constructor', 'prototype'].includes(key))
fail(path, 'unsafe object key');
if (key.length > 256) fail(path, 'field name is too long');
jsonTree(child, `${path}.${key}`, budget, depth + 1);
}
}
function visual(value, path, legacy) {
fields(value, path, [
'style',
@@ -141,8 +64,8 @@ export function validateSceneDocument(project) {
'installedBuiltInSceneIds',
'scenes',
]);
const version = project.version ?? 1;
if (![1, 2, 3].includes(version))
const version = Object.hasOwn(project, 'version') ? project.version : 1;
if (![1, 2, 3, 4].includes(version))
fail('$.version', 'unsupported scene project version');
const legacy = version < 3;
for (const key of ['createdAt', 'updatedAt'])
@@ -159,6 +82,7 @@ export function validateSceneDocument(project) {
'releaseLayerIds',
'appliedShotPacks',
'shots',
...(version >= 4 ? ['anchors'] : []),
]);
uniqueId(scene, path, sceneIds);
optional(scene, 'title', path, (v, p) => string(v, p, 4096));
@@ -198,6 +122,7 @@ export function validateSceneDocument(project) {
'layers',
'sourcePackId',
'sourcePackVersion',
...(version >= 4 ? ['move'] : []),
]);
uniqueId(shot, at, shotIds);
optional(shot, 'title', at, (v, p) => string(v, p, 4096));
@@ -209,21 +134,6 @@ export function validateSceneDocument(project) {
number(v, p, 0, 86400, legacy),
);
optional(shot, 'holdSec', at, (v, p) => number(v, p, 0, 86400, legacy));
optional(shot, 'camera', at, (camera, field) => {
const bounds = {
lat: [-90, 90],
lon: [-180, 180],
alt: [-12000, 1e9],
heading: [-360, 360],
pitch: [-90, 90],
roll: [-360, 360],
};
fields(camera, field, Object.keys(bounds));
for (const [key, range] of Object.entries(bounds))
optional(camera, key, field, (v, p) =>
number(v, p, ...range, legacy),
);
});
optional(shot, 'visual', at, (v, p) => visual(v, p, legacy));
optional(shot, 'layers', at, (layers, field) => {
object(layers, field);
@@ -238,6 +148,7 @@ export function validateSceneDocument(project) {
}
});
});
validateSceneCameras(scene, path, version);
});
return project;
}
+1 -1
View File
@@ -59,7 +59,7 @@ test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds surviv
const migrated = normalizeProject(
parseSceneDocument(JSON.stringify(project)),
);
assert.equal(migrated.version, 3);
assert.equal(migrated.version, 4);
assert.equal(migrated.scenes[0].shots[0].visual.bloom.intensity, 150);
assert.equal(migrated.scenes[0].shots[0].id, 'shot');
assert.deepEqual(
+94
View File
@@ -0,0 +1,94 @@
/** Shared bounded JSON and field checks for scene formats. */
export const SCENE_DOCUMENT_LIMITS = Object.freeze({
bytes: 5 * 1024 * 1024,
scenes: 256,
shots: 10000,
collection: 10000,
depth: 24,
nodes: 200000,
string: 65536,
});
/** A project error identifies a field without echoing its supplied value. */
export class SceneDocumentError extends Error {
constructor(path, reason) {
super(`${path}: ${reason}`);
this.name = 'SceneDocumentError';
this.path = path;
}
}
export const fail = (path, reason) => {
throw new SceneDocumentError(path, reason);
};
export const object = (value, path) => {
if (!value || typeof value !== 'object' || Array.isArray(value))
fail(path, 'expected an object');
};
export function fields(value, path, allowed) {
object(value, path);
for (const key of Object.keys(value)) {
if (!allowed.includes(key)) fail(`${path}.${key}`, 'unsupported field');
}
}
export function string(value, path, max = 256) {
if (typeof value !== 'string' || !value.trim() || value.length > max)
fail(path, `expected nonempty text, at most ${max} characters`);
}
export function number(value, path, min, max, legacy) {
const numeric =
legacy && typeof value === 'string' && value.trim() ? Number(value) : value;
if (
typeof numeric !== 'number' ||
!Number.isFinite(numeric) ||
numeric < min ||
numeric > max
)
fail(path, `expected a number from ${min} to ${max}`);
}
export function optional(value, key, path, check) {
if (Object.hasOwn(value, key)) check(value[key], `${path}.${key}`);
}
export function array(value, path, max = SCENE_DOCUMENT_LIMITS.collection) {
if (!Array.isArray(value) || value.length > max)
fail(path, `expected an array of at most ${max} entries`);
}
export function ids(value, path) {
array(value, path);
value.forEach((id, index) => string(id, `${path}[${index}]`));
}
export function uniqueId(value, path, seen) {
if (!Object.hasOwn(value, 'id')) return; // Legacy omissions receive an ID once during migration.
string(value.id, `${path}.id`);
if (seen.has(value.id)) fail(`${path}.id`, 'duplicate ID');
seen.add(value.id);
}
export function jsonTree(value, path, budget, depth = 0) {
if (
++budget.nodes > SCENE_DOCUMENT_LIMITS.nodes ||
depth > SCENE_DOCUMENT_LIMITS.depth
)
fail(path, 'project complexity limit exceeded');
if (value === null || typeof value === 'boolean') return;
if (typeof value === 'string') {
if (value.length > SCENE_DOCUMENT_LIMITS.string)
fail(path, 'text is too long');
return;
}
if (typeof value === 'number' && Number.isFinite(value)) return;
if (!value || typeof value !== 'object') fail(path, 'expected a JSON value');
if (
Object.getPrototypeOf(value) !== Object.prototype &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) !== null
)
fail(path, 'expected a JSON object');
const entries = Object.entries(value);
if (entries.length > SCENE_DOCUMENT_LIMITS.collection)
fail(path, 'too many entries');
for (const [key, child] of entries) {
if (['__proto__', 'constructor', 'prototype'].includes(key))
fail(path, 'unsafe object key');
if (key.length > 256) fail(path, 'field name is too long');
jsonTree(child, `${path}.${key}`, budget, depth + 1);
}
}
+6
View File
@@ -112,3 +112,9 @@ export {
SCENE_DOCUMENT_VERSION,
SCENE_DOCUMENT_LIMITS,
} from './document.js';
export {
resolveCameraPose,
resolveCameraMove,
sampleCameraMove,
} from './camera.js';
+15 -2
View File
@@ -1,3 +1,9 @@
import {
resolveCameraPose,
resolveCameraMove,
sampleCameraMove,
} from './camera.js';
const DEFAULT_SHOT_DURATION_SEC = 4;
const clamp01 = (value) => Math.max(0, Math.min(1, value));
@@ -78,7 +84,12 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
const holdElapsedSec = Math.max(0, shotElapsedSec - flightDurationSec);
const holdProgress =
holdDurationSec > 0 ? clamp01(holdElapsedSec / holdDurationSec) : 1;
const previousCamera = scene.shots[shotIndex - 1]?.camera || shot.camera;
const targetCamera = resolveCameraPose(scene, shot.camera);
const previousCamera = resolveCameraPose(
scene,
scene.shots[shotIndex - 1]?.camera || shot.camera,
);
const move = resolveCameraMove(scene, shot);
return {
sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
sceneElapsedSec: targetSec,
@@ -92,6 +103,8 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
holdElapsedSec,
cameraProgress,
holdProgress,
camera: cameraAtProgress(previousCamera, shot.camera, cameraProgress),
camera: move
? sampleCameraMove(move, cameraProgress)
: cameraAtProgress(previousCamera, targetCamera, cameraProgress),
};
}
+70
View File
@@ -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();
});
},
};
}
+85
View File
@@ -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
View File
@@ -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,
+27 -1
View File
@@ -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(); }
});
+1 -1
View File
@@ -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
View File
@@ -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)
+11
View File
@@ -1,3 +1,4 @@
import { createStateChannel } from '../app/stateChannel.js';
import * as Cesium from 'cesium';
import {
beginDeferredNavigation,
@@ -34,6 +35,9 @@ export class NavigationController {
cancelOrientation,
});
this._navigationGeneration = 0;
this._cameraHandoffs = createStateChannel(() => ({
generation: this._navigationGeneration,
}));
this._activeLocationSearchGeneration = null;
this._disposed = false;
}
@@ -45,6 +49,7 @@ export class NavigationController {
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
this.tracking;
this._navigationGeneration += 1;
this._cameraHandoffs?.publish();
// A newer destination owns the camera, so the last free-text search is no
// longer where we are. DEFERRED navigation opts out here and clears at the
// reassert seam instead: a geocode that never resolves moves no camera, and
@@ -247,8 +252,14 @@ export class NavigationController {
},
});
}
/** Subscribe to ownership changes without claiming the camera or exposing mutable state. */
subscribeCameraHandoff(listener) {
return this._cameraHandoffs.subscribe(listener, { emitCurrent: false });
}
stop() {
this._disposed = true;
this._cameraHandoffs?.publish();
this._cameraHandoffs?.destroy();
}
destroy() {
this.stop();
+5
View File
@@ -4,6 +4,11 @@
* New logic belongs with an owner, not in this forwarding surface.
*/
export class ShellFacade {
/** Observe camera ownership transfers through the navigation owner. */
subscribeCameraHandoff(listener) {
return this._navigation.subscribeCameraHandoff(listener);
}
get _models3dModeBtns() {
return this._aircraftDisplay._models3dModeBtns;
}