diff --git a/CHANGELOG.md b/CHANGELOG.md index f58c06b5b..c878c42c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 48ab18fe8..9dcbd379d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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). diff --git a/docs/DIRECTOR-CAMERA.md b/docs/DIRECTOR-CAMERA.md new file mode 100644 index 000000000..2145c699d --- /dev/null +++ b/docs/DIRECTOR-CAMERA.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. diff --git a/docs/DIRECTOR.md b/docs/DIRECTOR.md index 164ad7f2f..0aefbf0e3 100644 --- a/docs/DIRECTOR.md +++ b/docs/DIRECTOR.md @@ -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 diff --git a/docs/SCENE-DOCUMENT.md b/docs/SCENE-DOCUMENT.md index 985742d1b..35737156e 100644 --- a/docs/SCENE-DOCUMENT.md +++ b/docs/SCENE-DOCUMENT.md @@ -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. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f334c4e46..b84602e3f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9df4d9135..736800daf 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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": { diff --git a/scripts/qa-director-camera.mjs b/scripts/qa-director-camera.mjs new file mode 100644 index 000000000..62f0af5ff --- /dev/null +++ b/scripts/qa-director-camera.mjs @@ -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; diff --git a/src/director/camera.js b/src/director/camera.js new file mode 100644 index 000000000..059c7a873 --- /dev/null +++ b/src/director/camera.js @@ -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), + }; +} diff --git a/src/director/camera.test.mjs b/src/director/camera.test.mjs new file mode 100644 index 000000000..54a613d0f --- /dev/null +++ b/src/director/camera.test.mjs @@ -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/, + ); +}); diff --git a/src/director/cameraDocument.js b/src/director/cameraDocument.js new file mode 100644 index 000000000..1579d54a8 --- /dev/null +++ b/src/director/cameraDocument.js @@ -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), + ); + }); +} diff --git a/src/director/document.js b/src/director/document.js index d0ecdebb4..efa806ed8 100644 --- a/src/director/document.js +++ b/src/director/document.js @@ -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; } diff --git a/src/director/document.test.mjs b/src/director/document.test.mjs index d9a2f9ab4..692796921 100644 --- a/src/director/document.test.mjs +++ b/src/director/document.test.mjs @@ -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( diff --git a/src/director/documentFields.js b/src/director/documentFields.js new file mode 100644 index 000000000..681cc6482 --- /dev/null +++ b/src/director/documentFields.js @@ -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); + } +} diff --git a/src/director/playback.js b/src/director/playback.js index ff5620ed5..5c0354fa4 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -112,3 +112,9 @@ export { SCENE_DOCUMENT_VERSION, SCENE_DOCUMENT_LIMITS, } from './document.js'; + +export { + resolveCameraPose, + resolveCameraMove, + sampleCameraMove, +} from './camera.js'; diff --git a/src/director/timeline.js b/src/director/timeline.js index 782b073f7..7fd4cbbae 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -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), }; } diff --git a/src/scenes/cameraMotion.js b/src/scenes/cameraMotion.js new file mode 100644 index 000000000..fd40e7bf3 --- /dev/null +++ b/src/scenes/cameraMotion.js @@ -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(); + }); + }, + }; +} diff --git a/src/scenes/cameraMotion.test.mjs b/src/scenes/cameraMotion.test.mjs new file mode 100644 index 000000000..ecc16a3e3 --- /dev/null +++ b/src/scenes/cameraMotion.test.mjs @@ -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/, + ); +}); diff --git a/src/scenes/director.js b/src/scenes/director.js index 5c3c44a92..01a04f56a 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -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, diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index b1be372f4..1a27161d0 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -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(); } +}); diff --git a/src/scenes/playbackAdapter.js b/src/scenes/playbackAdapter.js index c3870c5d6..763ef1e96 100644 --- a/src/scenes/playbackAdapter.js +++ b/src/scenes/playbackAdapter.js @@ -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; }, diff --git a/src/scenes/project.js b/src/scenes/project.js index c71517ae4..d47a49ad7 100644 --- a/src/scenes/project.js +++ b/src/scenes/project.js @@ -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) diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 0cf05135b..b47dbfefe 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -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(); diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 56d4347f0..dc65e4fb9 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -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; }