mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# 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,
|
- Director validates bounded version-3 scene files before replacing a project,
|
||||||
preserves unreadable browser saves, migrates legacy bloom once and preserves
|
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.
|
zero-pitch/low-altitude camera and scope/detection edits. Project normalization has a separate owner.
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# God's Eye View Current State
|
# 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
|
Realtime voice composes separate connection, response/tool, Radio handoff, input
|
||||||
and audio-meter, cost, viewport-context and diagnostic owners. The existing
|
and audio-meter, cost, viewport-context and diagnostic owners. The existing
|
||||||
controller exports and session/backend contracts remain available. Tool protocol,
|
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
|
rules own presentation overrides and Nepal map fallback without changing saved
|
||||||
shots or content. Scene-document validation and legacy migration now have separate
|
shots or content. Scene-document validation and legacy migration now have separate
|
||||||
owners. Invalid imports preserve the current project; unreadable saved projects
|
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);
|
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).
|
data packs and interactions remain planned in [Director](DIRECTOR.md).
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -67,16 +67,17 @@ and river coordinates are bundled. See the
|
|||||||
existing recipes and content. Tests cover direct load, forward/backward seek,
|
existing recipes and content. Tests cover direct load, forward/backward seek,
|
||||||
replay, bounded media waits and cancellation at pending transitions.
|
replay, bounded media waits and cancellation at pending transitions.
|
||||||
2. **Versioned scene document — implemented for existing camera-pose shots.**
|
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
|
rejects malformed files before replacing current state; legacy migration
|
||||||
preserves IDs, pack bindings and visual edits. Unreadable saved files are
|
preserves IDs, pack bindings and visual edits. Unreadable saved files are
|
||||||
protected from fallback writes. Camera anchors, pack manifests and actions
|
protected from fallback writes. Camera anchors, pack manifests and actions
|
||||||
receive stable IDs with their respective later format extensions.
|
receive stable IDs with their respective later format extensions.
|
||||||
3. **Camera directions.** Define named geographic anchors and explicit
|
3. **Camera directions — implemented for pose-to-pose moves.**
|
||||||
move/hold segments: start and destination, duration, easing, heading, pitch,
|
[Version 4](DIRECTOR-CAMERA.md) adds scene-local geographic anchors, explicit
|
||||||
roll and stop duration. State coordinate units, altitude reference and
|
start/destination poses, easing, duration and holds. Playback and seek share
|
||||||
terrain-relative behavior. Support the current camera-pose shots first;
|
the same sampler. Ellipsoidal heights are explicit; terrain-relative input
|
||||||
paths, look-at targets and pauses follow with deterministic seek behavior.
|
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,
|
4. **Data packs and placement.** Define a pack manifest with format/version,
|
||||||
source, attribution/license and optional integrity/size metadata. Distinguish
|
source, attribution/license and optional integrity/size metadata. Distinguish
|
||||||
where bytes live (relative files, a URL, or an eventual bundle) from where
|
where bytes live (relative files, a URL, or an eventual bundle) from where
|
||||||
|
|||||||
+14
-15
@@ -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
|
Scenes exports a UTF-8 JSON project. Version 4 adds [camera directions](DIRECTOR-CAMERA.md)
|
||||||
version-3 exports need no conversion. Versions 1, 2 and unversioned legacy files
|
and is the write format. Versions 1, 2, 3 and unversioned legacy files migrate on
|
||||||
are migrated on import. Unknown versions or unsupported fields are rejected with
|
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.
|
a field path before the current project, selection or saved bytes change.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"scenes": [{
|
"scenes": [{
|
||||||
"id": "my-scene",
|
"id": "my-scene",
|
||||||
"title": "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
|
The root requires `scenes` (an array, which may be empty). Optional root fields
|
||||||
are `version`, `createdAt`, `updatedAt` and `installedBuiltInSceneIds` (string IDs).
|
are `version`, `createdAt`, `updatedAt` and `installedBuiltInSceneIds` (string IDs).
|
||||||
Each scene requires a `shots` array; optional fields are `id`, `title`,
|
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
|
`version` and `shotBindings` (original shot title to saved shot ID). These markers
|
||||||
retain the editor's existing pack-update and rename behavior.
|
retain the editor's existing pack-update and rename behavior.
|
||||||
|
|
||||||
Shots accept `id`, `title`, `durationSec`, `holdSec`, `camera`, `visual`, `layers`,
|
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
|
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.
|
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.
|
Missing titles and optional camera/visual fields receive the editor's defaults.
|
||||||
Empty scenes and intentionally empty projects remain empty.
|
Empty scenes and intentionally empty projects remain empty.
|
||||||
|
|
||||||
Camera latitude/longitude and heading/pitch/roll use degrees. Altitude uses the
|
Camera latitude/longitude and heading/pitch/roll use degrees. Altitude uses
|
||||||
existing camera-pose height in meters. Import preserves finite heights below
|
meters above the WGS84 ellipsoid. Import preserves finite heights below
|
||||||
100 meters and zero pitch, which previously fell through to defaults. Explicit
|
100 meters and zero pitch, which previously fell through to defaults. Anchors and explicit moves require the `ellipsoid` altitude reference;
|
||||||
altitude-reference and terrain-relative directions are a subsequent format step.
|
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
|
Flight durations retain the editor's minimum of 0.2 seconds and its 4-second
|
||||||
fallback for zero; holds may be zero.
|
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.
|
edits and storage write failures surface an unsaved toast.
|
||||||
|
|
||||||
Only authored project fields belong in this file. Runtime clocks, camera flights,
|
Only authored project fields belong in this file. Runtime clocks, camera flights,
|
||||||
loading progress and run diagnostics remain separate. Camera anchors, asset-pack
|
loading progress and run diagnostics remain separate. Version 4 supports scene-local camera anchors and authored moves. Asset-pack
|
||||||
manifests and declarative interactions are planned extensions, not accepted fields
|
manifests and declarative interactions remain planned extensions. They will
|
||||||
in version 3. They will receive stable IDs and their own validated migration when
|
receive stable IDs and their own validated migration when introduced. No scene assets, source links, licenses or contributor attribution
|
||||||
introduced. No scene assets, source links, licenses or contributor attribution
|
|
||||||
change with this document boundary.
|
change with this document boundary.
|
||||||
@@ -733,5 +733,8 @@
|
|||||||
"src/director/timeline.test.mjs",
|
"src/director/timeline.test.mjs",
|
||||||
"src/scenes/packs/registry.test.mjs",
|
"src/scenes/packs/registry.test.mjs",
|
||||||
"scripts/qa-director-timing.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"
|
||||||
]
|
]
|
||||||
@@ -618,7 +618,10 @@
|
|||||||
"src/director/playback.js",
|
"src/director/playback.js",
|
||||||
"src/director/clock.js",
|
"src/director/clock.js",
|
||||||
"src/director/timeline.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": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -648,7 +651,11 @@
|
|||||||
"src/scenes/packs/defaults.js",
|
"src/scenes/packs/defaults.js",
|
||||||
"src/scenes/packs/nepal.js",
|
"src/scenes/packs/nepal.js",
|
||||||
"src/director/document.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"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1755,7 +1762,11 @@
|
|||||||
"src/scenes/packs/defaults.js",
|
"src/scenes/packs/defaults.js",
|
||||||
"src/scenes/packs/nepal.js",
|
"src/scenes/packs/nepal.js",
|
||||||
"src/director/document.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": [
|
"external": [
|
||||||
"@mapbox/vector-tile",
|
"@mapbox/vector-tile",
|
||||||
@@ -2220,7 +2231,11 @@
|
|||||||
},
|
},
|
||||||
"ui-navigation": {
|
"ui-navigation": {
|
||||||
"exports": ["./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"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
"ui-share-restoration": {
|
"ui-share-restoration": {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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
@@ -1,98 +1,21 @@
|
|||||||
/** Bounded, renderer-independent validation for authored scene documents. */
|
/** Bounded, renderer-independent validation for authored scene documents. */
|
||||||
export const SCENE_DOCUMENT_VERSION = 3;
|
import {
|
||||||
export const SCENE_DOCUMENT_LIMITS = Object.freeze({
|
SCENE_DOCUMENT_LIMITS,
|
||||||
bytes: 5 * 1024 * 1024,
|
fail,
|
||||||
scenes: 256,
|
object,
|
||||||
shots: 10000,
|
fields,
|
||||||
collection: 10000,
|
string,
|
||||||
depth: 24,
|
number,
|
||||||
nodes: 200000,
|
optional,
|
||||||
string: 65536,
|
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) {
|
function visual(value, path, legacy) {
|
||||||
fields(value, path, [
|
fields(value, path, [
|
||||||
'style',
|
'style',
|
||||||
@@ -141,8 +64,8 @@ export function validateSceneDocument(project) {
|
|||||||
'installedBuiltInSceneIds',
|
'installedBuiltInSceneIds',
|
||||||
'scenes',
|
'scenes',
|
||||||
]);
|
]);
|
||||||
const version = project.version ?? 1;
|
const version = Object.hasOwn(project, 'version') ? project.version : 1;
|
||||||
if (![1, 2, 3].includes(version))
|
if (![1, 2, 3, 4].includes(version))
|
||||||
fail('$.version', 'unsupported scene project version');
|
fail('$.version', 'unsupported scene project version');
|
||||||
const legacy = version < 3;
|
const legacy = version < 3;
|
||||||
for (const key of ['createdAt', 'updatedAt'])
|
for (const key of ['createdAt', 'updatedAt'])
|
||||||
@@ -159,6 +82,7 @@ export function validateSceneDocument(project) {
|
|||||||
'releaseLayerIds',
|
'releaseLayerIds',
|
||||||
'appliedShotPacks',
|
'appliedShotPacks',
|
||||||
'shots',
|
'shots',
|
||||||
|
...(version >= 4 ? ['anchors'] : []),
|
||||||
]);
|
]);
|
||||||
uniqueId(scene, path, sceneIds);
|
uniqueId(scene, path, sceneIds);
|
||||||
optional(scene, 'title', path, (v, p) => string(v, p, 4096));
|
optional(scene, 'title', path, (v, p) => string(v, p, 4096));
|
||||||
@@ -198,6 +122,7 @@ export function validateSceneDocument(project) {
|
|||||||
'layers',
|
'layers',
|
||||||
'sourcePackId',
|
'sourcePackId',
|
||||||
'sourcePackVersion',
|
'sourcePackVersion',
|
||||||
|
...(version >= 4 ? ['move'] : []),
|
||||||
]);
|
]);
|
||||||
uniqueId(shot, at, shotIds);
|
uniqueId(shot, at, shotIds);
|
||||||
optional(shot, 'title', at, (v, p) => string(v, p, 4096));
|
optional(shot, 'title', at, (v, p) => string(v, p, 4096));
|
||||||
@@ -209,21 +134,6 @@ export function validateSceneDocument(project) {
|
|||||||
number(v, p, 0, 86400, legacy),
|
number(v, p, 0, 86400, legacy),
|
||||||
);
|
);
|
||||||
optional(shot, 'holdSec', at, (v, p) => 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, 'visual', at, (v, p) => visual(v, p, legacy));
|
||||||
optional(shot, 'layers', at, (layers, field) => {
|
optional(shot, 'layers', at, (layers, field) => {
|
||||||
object(layers, field);
|
object(layers, field);
|
||||||
@@ -238,6 +148,7 @@ export function validateSceneDocument(project) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
validateSceneCameras(scene, path, version);
|
||||||
});
|
});
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds surviv
|
|||||||
const migrated = normalizeProject(
|
const migrated = normalizeProject(
|
||||||
parseSceneDocument(JSON.stringify(project)),
|
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].visual.bloom.intensity, 150);
|
||||||
assert.equal(migrated.scenes[0].shots[0].id, 'shot');
|
assert.equal(migrated.scenes[0].shots[0].id, 'shot');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,3 +112,9 @@ export {
|
|||||||
SCENE_DOCUMENT_VERSION,
|
SCENE_DOCUMENT_VERSION,
|
||||||
SCENE_DOCUMENT_LIMITS,
|
SCENE_DOCUMENT_LIMITS,
|
||||||
} from './document.js';
|
} from './document.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
resolveCameraPose,
|
||||||
|
resolveCameraMove,
|
||||||
|
sampleCameraMove,
|
||||||
|
} from './camera.js';
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
resolveCameraPose,
|
||||||
|
resolveCameraMove,
|
||||||
|
sampleCameraMove,
|
||||||
|
} from './camera.js';
|
||||||
|
|
||||||
const DEFAULT_SHOT_DURATION_SEC = 4;
|
const DEFAULT_SHOT_DURATION_SEC = 4;
|
||||||
const clamp01 = (value) => Math.max(0, Math.min(1, value));
|
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 holdElapsedSec = Math.max(0, shotElapsedSec - flightDurationSec);
|
||||||
const holdProgress =
|
const holdProgress =
|
||||||
holdDurationSec > 0 ? clamp01(holdElapsedSec / holdDurationSec) : 1;
|
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 {
|
return {
|
||||||
sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
|
sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
|
||||||
sceneElapsedSec: targetSec,
|
sceneElapsedSec: targetSec,
|
||||||
@@ -92,6 +103,8 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
|
|||||||
holdElapsedSec,
|
holdElapsedSec,
|
||||||
cameraProgress,
|
cameraProgress,
|
||||||
holdProgress,
|
holdProgress,
|
||||||
camera: cameraAtProgress(previousCamera, shot.camera, cameraProgress),
|
camera: move
|
||||||
|
? sampleCameraMove(move, cameraProgress)
|
||||||
|
: cameraAtProgress(previousCamera, targetCamera, cameraProgress),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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.
|
* 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 { createStateChannel } from '../app/stateChannel.js';
|
||||||
import { SceneControls } from '../ui/scenes.js';
|
import { SceneControls } from '../ui/scenes.js';
|
||||||
import { buildPlaybackQueue, playSceneQueue } from '../director/playback.js';
|
import { buildPlaybackQueue, playSceneQueue } from '../director/playback.js';
|
||||||
@@ -84,6 +86,22 @@ export class SceneDirector {
|
|||||||
this.dataManager = dataManager;
|
this.dataManager = dataManager;
|
||||||
this._isMapStackAvailable = isMapStackAvailable;
|
this._isMapStackAvailable = isMapStackAvailable;
|
||||||
this._scenePacks = scenePacks;
|
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 */
|
/** @type {boolean} True while a scene run is in progress */
|
||||||
this._running = false;
|
this._running = false;
|
||||||
@@ -179,6 +197,9 @@ export class SceneDirector {
|
|||||||
this._destroyed = true;
|
this._destroyed = true;
|
||||||
this._sceneSeekGeneration++;
|
this._sceneSeekGeneration++;
|
||||||
this._visibilityUnsubscribe?.();
|
this._visibilityUnsubscribe?.();
|
||||||
|
this._cameraHandoffUnsubscribe?.();
|
||||||
|
this._removeCameraInput?.();
|
||||||
|
this._cameraMotion?.destroy();
|
||||||
this._controls?.destroy();
|
this._controls?.destroy();
|
||||||
this._state.destroy();
|
this._state.destroy();
|
||||||
this._destroyPromise = Promise.resolve().then(async () => {
|
this._destroyPromise = Promise.resolve().then(async () => {
|
||||||
@@ -903,7 +924,9 @@ export class SceneDirector {
|
|||||||
const camera = this.styleManager.getCameraState();
|
const camera = this.styleManager.getCameraState();
|
||||||
if (!camera) return;
|
if (!camera) return;
|
||||||
|
|
||||||
shot.camera = camera;
|
shot.camera = shot.move
|
||||||
|
? { ...camera, altitudeReference: 'ellipsoid' }
|
||||||
|
: camera;
|
||||||
shot.visual = this.styleManager.getVisualState();
|
shot.visual = this.styleManager.getVisualState();
|
||||||
shot.layers = this._captureLayerStates();
|
shot.layers = this._captureLayerStates();
|
||||||
|
|
||||||
@@ -955,11 +978,12 @@ export class SceneDirector {
|
|||||||
async _loadShot(
|
async _loadShot(
|
||||||
sceneId,
|
sceneId,
|
||||||
shotId,
|
shotId,
|
||||||
{ flyDuration = 2.2, fromCamera = null, sceneSeek = null } = {},
|
{ flyDuration = null, fromCamera = null, sceneSeek = null } = {},
|
||||||
) {
|
) {
|
||||||
if (this._running) return { started: false, reason: 'already-running' };
|
if (this._running) return { started: false, reason: 'already-running' };
|
||||||
const { scene, shot } = this._getShot(sceneId, shotId);
|
const { scene, shot } = this._getShot(sceneId, shotId);
|
||||||
if (!scene || !shot) return { started: false, reason: 'shot-not-found' };
|
if (!scene || !shot) return { started: false, reason: 'shot-not-found' };
|
||||||
|
flyDuration ??= shot.move ? shot.durationSec : 2.2;
|
||||||
const previousScene =
|
const previousScene =
|
||||||
this._project.scenes.find((item) => item.id === this._loadedSceneId) ||
|
this._project.scenes.find((item) => item.id === this._loadedSceneId) ||
|
||||||
null;
|
null;
|
||||||
@@ -972,6 +996,7 @@ export class SceneDirector {
|
|||||||
// means an in-flight layer transition is cancelled (and rolled back by the
|
// means an in-flight layer transition is cancelled (and rolled back by the
|
||||||
// manager) rather than merely ignored once it has already committed.
|
// manager) rather than merely ignored once it has already committed.
|
||||||
this._cancelActiveSceneTravel();
|
this._cancelActiveSceneTravel();
|
||||||
|
this._usesAuthoredCamera = !!shot.move;
|
||||||
this._loadAbort?.abort();
|
this._loadAbort?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
this._loadAbort = controller;
|
this._loadAbort = controller;
|
||||||
@@ -1017,7 +1042,9 @@ export class SceneDirector {
|
|||||||
return { started: false, reason: 'layers-refused' };
|
return { started: false, reason: 'layers-refused' };
|
||||||
}
|
}
|
||||||
if (seekState) {
|
if (seekState) {
|
||||||
this._setCameraView(seekState.camera || shot.camera);
|
this._setCameraView(
|
||||||
|
seekState.camera || resolveCameraPose(scene, shot.camera),
|
||||||
|
);
|
||||||
this._setProgress(seekState.sceneProgress);
|
this._setProgress(seekState.sceneProgress);
|
||||||
this._publishSceneClock(scene, shot, seekState.sceneElapsedSec, {
|
this._publishSceneClock(scene, shot, seekState.sceneElapsedSec, {
|
||||||
running: false,
|
running: false,
|
||||||
@@ -1044,10 +1071,10 @@ export class SceneDirector {
|
|||||||
);
|
);
|
||||||
const cameraTravel = this._beginShotTravel(scene, shot, flyDuration);
|
const cameraTravel = this._beginShotTravel(scene, shot, flyDuration);
|
||||||
try {
|
try {
|
||||||
// _flyCamera calls Cesium's flyTo synchronously before yielding. Publish
|
// Camera motion starts synchronously before yielding. Publish the
|
||||||
// the trail phase immediately afterward so its clock overlaps actual
|
// trail phase immediately afterward so its clock overlaps actual
|
||||||
// camera motion, never an earlier asynchronous layer-reconcile wait.
|
// 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);
|
this._publishShotTravel(scene, shot, cameraTravel);
|
||||||
await flight;
|
await flight;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1171,6 +1198,8 @@ export class SceneDirector {
|
|||||||
|
|
||||||
/** Revoke layer-owned travel motion before a newer load, STOP, or teardown. */
|
/** Revoke layer-owned travel motion before a newer load, STOP, or teardown. */
|
||||||
_cancelActiveSceneTravel() {
|
_cancelActiveSceneTravel() {
|
||||||
|
this._cameraMotion?.cancel();
|
||||||
|
this._usesAuthoredCamera = false;
|
||||||
// The opening locator owns an additional delayed approach/orbit even after
|
// The opening locator owns an additional delayed approach/orbit even after
|
||||||
// the director's authored flight has settled. Revoke it before cancelling
|
// the director's authored flight has settled. Revoke it before cancelling
|
||||||
// the camera, whose moveEnd/complete callbacks may already be queued.
|
// 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];
|
scene.shots[(shotIndex - 1 + scene.shots.length) % scene.shots.length];
|
||||||
const result = await this.loadShot(sceneId, shotId, {
|
const result = await this.loadShot(sceneId, shotId, {
|
||||||
flyDuration: shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
|
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' };
|
return result || { started: false, reason: 'cancelled' };
|
||||||
}
|
}
|
||||||
@@ -1291,7 +1322,8 @@ export class SceneDirector {
|
|||||||
/** Read timing diagnostics without exposing timer handles or mutable clock state. */
|
/** Read timing diagnostics without exposing timer handles or mutable clock state. */
|
||||||
getPlaybackTimingState() {
|
getPlaybackTimingState() {
|
||||||
return {
|
return {
|
||||||
activeTimers: this._clock.activeTimers,
|
activeTimers:
|
||||||
|
this._clock.activeTimers + (this._cameraMotion?.active ? 1 : 0),
|
||||||
snapshot: this._clock.snapshot,
|
snapshot: this._clock.snapshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1345,7 +1377,9 @@ export class SceneDirector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!applied) return false;
|
if (!applied) return false;
|
||||||
this._setCameraView(seekState.camera || shot.camera);
|
this._setCameraView(
|
||||||
|
seekState.camera || resolveCameraPose(scene, shot.camera),
|
||||||
|
);
|
||||||
this._setProgress(seekState.sceneProgress);
|
this._setProgress(seekState.sceneProgress);
|
||||||
this._publishSceneClock(scene, shot, seekState.sceneElapsedSec, {
|
this._publishSceneClock(scene, shot, seekState.sceneElapsedSec, {
|
||||||
running: false,
|
running: false,
|
||||||
@@ -1419,10 +1453,13 @@ export class SceneDirector {
|
|||||||
// Older/headless style managers may predate the facade — proceed then.
|
// Older/headless style managers may predate the facade — proceed then.
|
||||||
if (typeof this.styleManager?.runImmediateNavigation !== 'function')
|
if (typeof this.styleManager?.runImmediateNavigation !== 'function')
|
||||||
return true;
|
return true;
|
||||||
const claimed = this.styleManager.runImmediateNavigation(
|
let claimed;
|
||||||
'scene',
|
this._claimingCamera = true;
|
||||||
() => true,
|
try {
|
||||||
);
|
claimed = this.styleManager.runImmediateNavigation('scene', () => true);
|
||||||
|
} finally {
|
||||||
|
this._claimingCamera = false;
|
||||||
|
}
|
||||||
if (claimed === false) {
|
if (claimed === false) {
|
||||||
this._updateStatus('Camera unavailable — exit cockpit first');
|
this._updateStatus('Camera unavailable — exit cockpit first');
|
||||||
return false;
|
return false;
|
||||||
@@ -1444,7 +1481,7 @@ export class SceneDirector {
|
|||||||
),
|
),
|
||||||
orientation: {
|
orientation: {
|
||||||
heading: Cesium.Math.toRadians(cameraState.heading || 0),
|
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),
|
roll: Cesium.Math.toRadians(cameraState.roll || 0),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1581,6 +1618,8 @@ export class SceneDirector {
|
|||||||
this._loadGeneration++;
|
this._loadGeneration++;
|
||||||
this._clock.stopShot();
|
this._clock.stopShot();
|
||||||
|
|
||||||
|
this._usesAuthoredCamera = queue.some(({ shot }) => !!shot.move);
|
||||||
|
|
||||||
// Transition to running state
|
// Transition to running state
|
||||||
this._running = true;
|
this._running = true;
|
||||||
this._previewRun = preview;
|
this._previewRun = preview;
|
||||||
@@ -1957,6 +1996,23 @@ export class SceneDirector {
|
|||||||
return true;
|
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
|
* Fly the Cesium camera to the given position over the specified duration
|
||||||
* using cubic ease-in-out. Resolves when the flight completes, is cancelled,
|
* using cubic ease-in-out. Resolves when the flight completes, is cancelled,
|
||||||
@@ -1998,7 +2054,7 @@ export class SceneDirector {
|
|||||||
destination,
|
destination,
|
||||||
orientation: {
|
orientation: {
|
||||||
heading: Cesium.Math.toRadians(cameraState.heading || 0),
|
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),
|
roll: Cesium.Math.toRadians(cameraState.roll || 0),
|
||||||
},
|
},
|
||||||
duration,
|
duration,
|
||||||
|
|||||||
@@ -1443,7 +1443,7 @@ test('unsupported stored documents cannot be overwritten by fallback edits', asy
|
|||||||
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
|
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
|
||||||
await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) });
|
await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) });
|
||||||
assert.equal(director._storageReadError, null);
|
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(); }
|
} finally { restore(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1491,3 +1491,29 @@ test('invalid authored edits cannot persist an unreadable project over a good sa
|
|||||||
assert.match(notice, /camera.lat/);
|
assert.match(notice, /camera.lat/);
|
||||||
} finally { restore(); }
|
} 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;
|
const duration = shot.durationSec || defaultShotDurationSec;
|
||||||
timing = director._startSceneClockTicker(scene, shot, token);
|
timing = director._startSceneClockTicker(scene, shot, token);
|
||||||
travel = director._beginShotTravel(scene, shot, duration);
|
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);
|
director._publishShotTravel(scene, shot, travel);
|
||||||
return flight;
|
return flight;
|
||||||
},
|
},
|
||||||
|
|||||||
+26
-10
@@ -67,11 +67,11 @@ function normalizeBloomState(
|
|||||||
{ projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {},
|
{ projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {},
|
||||||
) {
|
) {
|
||||||
// Determine which bloom scale the stored value was encoded under.
|
// 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 explicitVersion = Number(rawBloom.version);
|
||||||
const bloomVersion = Number.isFinite(explicitVersion)
|
const bloomVersion = Number.isFinite(explicitVersion)
|
||||||
? explicitVersion
|
? explicitVersion
|
||||||
: projectVersion >= PROJECT_VERSION
|
: projectVersion >= 3
|
||||||
? BLOOM_SCALE_VERSION
|
? BLOOM_SCALE_VERSION
|
||||||
: 1;
|
: 1;
|
||||||
|
|
||||||
@@ -231,14 +231,29 @@ export function normalizeShot(
|
|||||||
Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC,
|
Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC,
|
||||||
),
|
),
|
||||||
holdSec: Math.max(0, Number(rawShot?.holdSec) || 0),
|
holdSec: Math.max(0, Number(rawShot?.holdSec) || 0),
|
||||||
camera: {
|
camera: camera.anchorId
|
||||||
lat: Number(camera.lat) || 0,
|
? {
|
||||||
lon: Number(camera.lon) || 0,
|
anchorId: camera.anchorId,
|
||||||
alt: Number.isFinite(Number(camera.alt)) ? Number(camera.alt) : 800,
|
heading: Number(camera.heading) || 0,
|
||||||
heading: Number(camera.heading) || 0,
|
pitch: Number.isFinite(Number(camera.pitch))
|
||||||
pitch: Number.isFinite(Number(camera.pitch)) ? Number(camera.pitch) : -35,
|
? Number(camera.pitch)
|
||||||
roll: Number(camera.roll) || 0,
|
: -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: {
|
visual: {
|
||||||
style: visual.style || 'normal',
|
style: visual.style || 'normal',
|
||||||
bloom: normalizeBloomState(bloom, {
|
bloom: normalizeBloomState(bloom, {
|
||||||
@@ -331,6 +346,7 @@ export function normalizeProject(rawProject) {
|
|||||||
return {
|
return {
|
||||||
id: scene?.id || uid('scene'),
|
id: scene?.id || uid('scene'),
|
||||||
title: scene?.title || `Scene ${sceneIdx + 1}`,
|
title: scene?.title || `Scene ${sceneIdx + 1}`,
|
||||||
|
...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}),
|
||||||
releaseLayerIds: [
|
releaseLayerIds: [
|
||||||
...new Set(
|
...new Set(
|
||||||
(Array.isArray(scene?.releaseLayerIds)
|
(Array.isArray(scene?.releaseLayerIds)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createStateChannel } from '../app/stateChannel.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import {
|
||||||
beginDeferredNavigation,
|
beginDeferredNavigation,
|
||||||
@@ -34,6 +35,9 @@ export class NavigationController {
|
|||||||
cancelOrientation,
|
cancelOrientation,
|
||||||
});
|
});
|
||||||
this._navigationGeneration = 0;
|
this._navigationGeneration = 0;
|
||||||
|
this._cameraHandoffs = createStateChannel(() => ({
|
||||||
|
generation: this._navigationGeneration,
|
||||||
|
}));
|
||||||
this._activeLocationSearchGeneration = null;
|
this._activeLocationSearchGeneration = null;
|
||||||
this._disposed = false;
|
this._disposed = false;
|
||||||
}
|
}
|
||||||
@@ -45,6 +49,7 @@ export class NavigationController {
|
|||||||
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
|
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
|
||||||
this.tracking;
|
this.tracking;
|
||||||
this._navigationGeneration += 1;
|
this._navigationGeneration += 1;
|
||||||
|
this._cameraHandoffs?.publish();
|
||||||
// A newer destination owns the camera, so the last free-text search is no
|
// 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
|
// 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
|
// 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() {
|
stop() {
|
||||||
this._disposed = true;
|
this._disposed = true;
|
||||||
|
this._cameraHandoffs?.publish();
|
||||||
|
this._cameraHandoffs?.destroy();
|
||||||
}
|
}
|
||||||
destroy() {
|
destroy() {
|
||||||
this.stop();
|
this.stop();
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
* New logic belongs with an owner, not in this forwarding surface.
|
* New logic belongs with an owner, not in this forwarding surface.
|
||||||
*/
|
*/
|
||||||
export class ShellFacade {
|
export class ShellFacade {
|
||||||
|
/** Observe camera ownership transfers through the navigation owner. */
|
||||||
|
subscribeCameraHandoff(listener) {
|
||||||
|
return this._navigation.subscribeCameraHandoff(listener);
|
||||||
|
}
|
||||||
|
|
||||||
get _models3dModeBtns() {
|
get _models3dModeBtns() {
|
||||||
return this._aircraftDisplay._models3dModeBtns;
|
return this._aircraftDisplay._models3dModeBtns;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user