From 160e4456777ac91f868ea8fd24b6ce7d34155e9c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 04:49:46 +0400 Subject: [PATCH] Keep authored scene playback, pack media and geographic interactions across globes --- scripts/package-boundaries.json | 1 + scripts/qa-director-engine-switch.mjs | 314 ++++++++++++++++++++++++++ src/app/globeEngine.test.mjs | 4 +- src/app/navaraMode.js | 13 ++ src/app/scene.js | 11 +- src/app/tools.js | 27 ++- src/cameraHandoff.test.mjs | 4 +- src/layers/launches/replay.js | 4 +- src/main.js | 2 + src/scenes/dataPacks/controller.js | 12 +- src/scenes/dataPacks/geometry.js | 5 +- src/scenes/dataPacks/navara.js | 248 ++++++++++++++++++++ src/scenes/dataPacks/presentation.js | 79 +++++-- src/scenes/director.js | 184 ++++++++++++--- src/scenes/director.test.mjs | 41 ++++ src/scenes/interactions.js | 94 +++++--- src/scenes/playbackAdapter.js | 16 +- src/standalone/application.js | 1 + 18 files changed, 959 insertions(+), 101 deletions(-) create mode 100644 scripts/qa-director-engine-switch.mjs create mode 100644 src/scenes/dataPacks/navara.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 1bbb0614a..dfaab3c28 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3681,6 +3681,7 @@ "src/layers/directions/render/navara.js", "src/layers/traffic/render/navara.js", "src/layers/streetLevel/render/navara.js", + "src/scenes/dataPacks/navara.js", "src/layers/shared/render/navaraStory.js", "src/layers/bhoteKoshi/render/navara.js", "src/layers/weather/render/navara.js", diff --git a/scripts/qa-director-engine-switch.mjs b/scripts/qa-director-engine-switch.mjs new file mode 100644 index 000000000..ca74a18ff --- /dev/null +++ b/scripts/qa-director-engine-switch.mjs @@ -0,0 +1,314 @@ +import assert from 'node:assert/strict'; +import sharp from 'sharp'; +import puppeteer from 'puppeteer'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; +import { switchGlobe } from './qa-switch-globe.mjs'; +import { fromDegrees } from '../src/geo/cartesian3.js'; +const url = new URL(process.argv[2]); +if ( + !['localhost', '127.0.0.1'].includes(url.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + url.port, + ) +) + throw Error('Supply local keyless preview URL'); +url.search = '?engine=navara&welcome=0'; +url.hash = + 'v=2&lat=30.2672&lon=-97.742&alt=4000&pitch=-90&heading=0&map=sentinel2&l='; +const png = await sharp({ + create: { width: 64, height: 64, channels: 4, background: '#fd2233' }, +}) + .png() + .toBuffer(); +const features = [ + { + id: 'point', + geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] }, + }, + { + id: 'line', + geometry: { + type: 'LineString', + coordinates: [ + [-97.745, 30.268, 400], + [-97.735, 30.268, 400], + ], + }, + }, + { + id: 'polygon', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-97.747, 30.264, 450], + [-97.74, 30.264, 450], + [-97.74, 30.267, 450], + [-97.747, 30.264, 450], + ], + ], + }, + }, +].map((f) => ({ ...f, type: 'Feature' })); +const wav = Buffer.alloc(44 + 16000); +wav.write('RIFF'); +wav.writeUInt32LE(wav.length - 8, 4); +wav.write('WAVEfmt ', 8); +wav.writeUInt32LE(16, 16); +wav.writeUInt16LE(1, 20); +wav.writeUInt16LE(1, 22); +wav.writeUInt32LE(8000, 24); +wav.writeUInt32LE(16000, 28); +wav.writeUInt16LE(2, 32); +wav.writeUInt16LE(16, 34); +wav.write('data', 36); +wav.writeUInt32LE(16000, 40); +const pack = (id, format, path, placement) => ({ + id, + version: 1, + format, + source: { adapter: 'assets', path }, + attribution: { text: 'Local fixture', license: 'CC0-1.0' }, + placement, +}); +const camera = { + lat: 30.2672, + lon: -97.742, + alt: 4000, + heading: 0, + pitch: -90, + roll: 0, + altitudeReference: 'ellipsoid', +}; +const shot = { + id: 'one', + title: 'Pack view', + camera, + durationSec: 5, + holdSec: 3, + visual: { style: 'normal', mapStack: 'sentinel2' }, + layers: { earthquakes: true }, + dataPackIds: ['data', 'image', 'audio'], + interactions: features.map((f) => ({ + id: f.id, + label: 'Show ' + f.id, + target: { packId: 'data', featureId: f.id }, + action: { type: 'card', text: f.id }, + })), +}; +const project = { + version: 6, + scenes: [ + { + id: 'parity', + title: 'Test project', + anchors: [ + { + id: 'station', + lat: 30.271, + lon: -97.735, + alt: 500, + altitudeReference: 'ellipsoid', + }, + ], + dataPacks: [ + pack('data', 'geojson', 'data.json', { + altitudeReference: 'ellipsoid', + }), + pack('image', 'image', 'image.png', { + altitudeReference: 'ellipsoid', + bounds: [-97.738, 30.264, -97.732, 30.267], + height: 500, + }), + pack('audio', 'media', 'audio.wav', { anchorId: 'station' }), + ], + shots: [ + shot, + { + ...shot, + id: 'two', + title: 'Next shot', + camera: { ...camera, heading: 15 }, + move: { from: camera, easing: 'linear' }, + dataPackIds: [], + interactions: [], + }, + ], + }, + ], +}; +const b = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const p = await b.newPage(); + await blockPaidQaMaps(p); + await p.setViewport({ width: 1280, height: 900 }); + const errors = []; + p.on('pageerror', (e) => { + errors.push(e.message); + console.log('error', e.message); + }); + const reads = []; + await p.setRequestInterception(true); + p.on('request', (req) => { + const path = new URL(req.url()).pathname; + if (path.startsWith('/scene-assets/')) { + reads.push(path); + const file = path.split('/').pop(); + return req.respond({ + status: 200, + contentType: + file === 'data.json' + ? 'application/geo+json' + : file === 'audio.wav' + ? 'audio/wav' + : 'image/png', + body: + file === 'data.json' + ? JSON.stringify({ type: 'FeatureCollection', features }) + : file === 'audio.wav' + ? wav + : png, + }); + } + if (new URL(req.url()).hostname === 'earthquake.usgs.gov') + return req.respond({ + status: 200, + contentType: 'application/json', + headers: { 'Access-Control-Allow-Origin': '*' }, + body: '{"type":"FeatureCollection","features":[]}', + }); + return req.continue(); + }); + await p.goto(url.href); + await p.waitForFunction( + () => + window.__godsEyeView?.sceneDirector && + document.querySelector('#loading-screen.hidden'), + ); + await p.evaluate(async (project) => { + const a = window.__godsEyeView; + if (window.__GOOGLE_MAPS_API_KEY__ || a.tileset) + throw Error('Expected keyless preview'); + await a.styleManager.initialRestorePromise; + window.director = a.sceneDirector; + await window.director.importProjectFile( + new File([JSON.stringify(project)], 'qa.json'), + ); + const out = await window.director.loadShot('parity', 'one', { + flyDuration: 0.2, + }); + if (!out.started) + throw Error( + 'Pack load failed: ' + + JSON.stringify(out) + + ' ' + + window.director._presentation.status, + ); + }, project); + await p.waitForSelector('[data-director-pack="audio"] audio'); + await p.evaluate(() => { + window.qaAudio = document.querySelector( + '[data-director-pack="audio"] audio', + ); + }); + const at = (world) => + p.evaluate((world) => { + const a = window.__godsEyeView; + const xy = a.engine.projector.worldToWindow(world), + rect = a.engine.host.canvas.getBoundingClientRect(); + return { x: xy.x + rect.x, y: xy.y + rect.y }; + }, world); + for (const kind of ['navara', 'cesium', 'navara']) { + if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) + await switchGlobe(p, kind); + await new Promise((r) => setTimeout(r, 500)); + const st = await p.evaluate(() => ({ + same: window.director === window.__godsEyeView.sceneDirector, + media: + window.qaAudio === + document.querySelector('[data-director-pack="audio"] audio'), + packs: window.director.getDataPackState(), + actions: window.director.getInteractionState(), + })); + assert.ok( + st.same && st.media && st.packs.count === 3 && st.actions.count === 3, + ); + const xy = await at(fromDegrees(-97.742, 30.27, 450)); + await p.mouse.click(xy.x, xy.y); + await p.waitForFunction( + () => document.activeElement?.dataset.directorAction === 'point', + ); + await p.keyboard.press('Enter'); + await p.waitForFunction( + () => window.director.getInteractionState().selected === 'point', + ); + for (const [id, coords] of [ + ['line', [-97.74, 30.268, 400]], + ['polygon', [-97.742, 30.265, 450]], + ]) { + const hit = await at(fromDegrees(...coords)); + await p.mouse.click(hit.x, hit.y); + await p.waitForFunction( + (id) => document.activeElement?.dataset.directorAction === id, + { timeout: 10000 }, + id, + ); + } + const imageAt = await at(fromDegrees(-97.735, 30.2655, 500)); + const sample = await sharp(await p.screenshot()) + .extract({ + left: Math.round(imageAt.x), + top: Math.round(imageAt.y), + width: 1, + height: 1, + }) + .removeAlpha() + .raw() + .toBuffer(); + console.log(kind, st.packs.count, st.actions.count, Array.from(sample)); + assert.ok( + sample[0] > 100 && sample[0] > sample[1] * 2, + 'authored image really draws', + ); + assert.equal( + reads.length, + 3, + 'packs are not acquired again on a globe change', + ); + } + await p.evaluate(() => { + window.qaRun = window.director.startScene('parity', { + single: true, + preview: false, + }); + }); + await p.waitForFunction( + () => + window.director.running && + window.director.getPlaybackTimingState().snapshot?.shotId === 'one', + ); + await switchGlobe(p, 'cesium'); + assert.ok( + await p.evaluate( + () => + window.director.running && + window.director === window.__godsEyeView.sceneDirector, + ), + ); + await p.waitForFunction( + () => window.director.getPlaybackTimingState().snapshot?.shotId === 'two', + { timeout: 20000 }, + ); + await switchGlobe(p, 'navara'); + const complete = await p.evaluate(() => window.qaRun); + assert.ok(complete.started && complete.shots === 2); + assert.deepEqual(errors, []); + console.log( + 'PASS authored packs, geographic picks, same player, original multi-shot timeline', + ); +} finally { + await b.close(); +} diff --git a/src/app/globeEngine.test.mjs b/src/app/globeEngine.test.mjs index 27f80e0b6..6793672ff 100644 --- a/src/app/globeEngine.test.mjs +++ b/src/app/globeEngine.test.mjs @@ -52,8 +52,8 @@ test('the scene constructs the engine with the viewer and returns it beside the assert.ok(engineAt > viewerTeardown, 'engine teardown must precede viewer'); assert.match(scene, /tilesetRef\.current = tileset;/); assert.match( - scene, - /return \{ viewer, engine, tileset, mapStackController, operations \};/, + scene.replace(/\s+/g, ' '), + /return \{ viewer, engine, tileset, mapStackController, operations, packRenderer,? \};/, ); }); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 13193d201..2b652f165 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -310,9 +310,22 @@ export async function createNavaraScene({ engine, (credits ?? DATA_CREDITS).filter(({ key }) => NAVARA_CREDIT_KEYS.has(key)), ); + let packRenderer; + const { createNavaraPackRenderer } = + await import('../scenes/dataPacks/navara.js'); + const packGraphics = () => + (packRenderer ??= createNavaraPackRenderer( + engine, + navaraViews.get(engine), + )); return { viewer: null, engine, + packRenderer: { + geometry: (...args) => packGraphics().geometry(...args), + image: (...args) => packGraphics().image(...args), + media: (...args) => packGraphics().media(...args), + }, tileset: null, suspend() { uninstallRenderGovernor(engine); diff --git a/src/app/scene.js b/src/app/scene.js index 46ea0a770..964955110 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -54,7 +54,7 @@ export async function createApplicationScene({ signal, ...resources, }); - const { viewer, engine, tileset, mapStackController } = rendererCache + const { viewer, engine, tileset, mapStackController, packRenderer } = rendererCache ? await rendererCache.acquire(options.engineKind, build, defer) : await build({ defer }); if (layerSession) { @@ -65,7 +65,14 @@ export async function createApplicationScene({ mapStackController.getActiveId() === 'photoreal', ); } - return { viewer, engine, tileset, mapStackController, operations }; + return { + viewer, + engine, + tileset, + mapStackController, + operations, + packRenderer, + }; } async function createRenderer({ diff --git a/src/app/tools.js b/src/app/tools.js index 0ad6a2e2b..ef25ac083 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -35,22 +35,37 @@ export function createApplicationTools({ startChrome, onSceneDirector, sceneDataPacks, + layerSession, signal, defer, }) { const { viewer, engine, tileset, mapStackController, operations } = scene; const { styleManager, weatherEffects, cockpitCloudEffects } = controls; const { dataManager } = data; - const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, { - engine, - dataPacks: sceneDataPacks, - isMapStackAvailable: (id) => - mapStackController?.isStackAvailable(id) === true, + const isMapStackAvailable = id => mapStackController?.isStackAvailable(id) === true; + const sceneDirector = layerSession?.director ?? new SceneDirector(viewer, styleManager, dataManager, { + engine, dataPacks: {...sceneDataPacks,native:scene.packRenderer}, isMapStackAvailable, }); + if (layerSession?.director) + sceneDirector.attachScene( + viewer, + engine, + styleManager, + scene.packRenderer, + isMapStackAvailable, + ); + if (layerSession) layerSession.director = sceneDirector; dataManager.layers .get('bhote-koshi-2026') ?.module.attachSceneController(sceneDirector); - defer(() => sceneDirector.destroy()); + defer(() => { + if (layerSession?.handoffLayers && !layerSession.lifetime.signal.aborted) { + sceneDirector.suspendScene(); + } else { + if (layerSession) layerSession.director = null; + return sceneDirector.destroy(); + } + }); onSceneDirector?.(sceneDirector); // Without a viewer (the Navara mode) the tools load with that mode's code. if (!viewer) { diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index b7239f280..589a69d70 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -303,10 +303,10 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () = ordered(dispose, [ 'this._disposed = true;', 'this._layerBindings.stop();', - 'this._navigation.destroy();', + 'this._navigation.destroy(', 'await this._contextControls.restoreForDisposal();', ], 'dispose invalidation'); - assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\)/); + assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\{cancelPendingSelection: !retainScene\}\)/); }); test('teardown synchronously closes immediate camera entry points', () => { diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index 5b2fae800..2b5f95d9b 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -280,7 +280,9 @@ export function createReplay({ return false; layerState._replayPausedAtMs = appClock.now(); layerState._replayPaused = true; - layerState._replayTracks.get(layerState._replayCameraLaunchId)?.beginReplayFrame?.(); + layerState._replayTracks + ?.get(layerState._replayCameraLaunchId) + ?.beginReplayFrame?.(); syncReplayButton(); return true; } diff --git a/src/main.js b/src/main.js index 88c6ec7ba..190ab1973 100644 --- a/src/main.js +++ b/src/main.js @@ -41,6 +41,7 @@ async function start(next, engineSwitch = false, cockpit = null) { layerSession.restoringView = false; for (const {module} of layerSession.manager.layers.values()) module.resumeViewport?.(); if (layerSession.handoffLayers) layerSession.manager.layers.get('directions')?.module.resumeCamera?.(); + layerSession.director?.resumeScene(); // Only resume the aircraft that actually survived the handoff. A history // entry may select a different subject, and a stale track may disappear. const target = style.getAircraftTrackingTarget(); @@ -144,6 +145,7 @@ async function reconcile(remember = false) { if (layerSession.handoffLayers) { manager.layers.get('directions')?.module.suspendCamera?.(); manager.layers.get('satellites')?.module.suspendCamera?.(); + layerSession.director?.suspendScene(); } renderers.retain(kind); await application.destroy(); diff --git a/src/scenes/dataPacks/controller.js b/src/scenes/dataPacks/controller.js index 42800845a..052267874 100644 --- a/src/scenes/dataPacks/controller.js +++ b/src/scenes/dataPacks/controller.js @@ -2,14 +2,22 @@ import { createDataPackSession } from '../../director/packs/session.js'; import { createPackPresentations } from './presentation.js'; /** Bind selected shot packs to one viewer and expose copied lifecycle diagnostics. */ -export function createSceneDataPacks(viewer, { sources = {} } = {}) { +export function createSceneDataPacks( + viewer, + { sources = {}, native = null, container = viewer?.container } = {}, +) { const targets = new Map(); + const presentations = createPackPresentations(viewer, targets, { + native, + container, + }); const session = createDataPackSession({ sources, - adapters: createPackPresentations(viewer, targets), + adapters: presentations, }); return { ...session, + setScene: presentations.setScene, sourceIds: () => Object.keys(sources), getTargets: () => new Map(targets), apply(scene, shot, token) { diff --git a/src/scenes/dataPacks/geometry.js b/src/scenes/dataPacks/geometry.js index f81b1e58f..00b391c8c 100644 --- a/src/scenes/dataPacks/geometry.js +++ b/src/scenes/dataPacks/geometry.js @@ -6,7 +6,10 @@ const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p); export function renderPackGeometry({ asset, pack }, handle) { if (!['application/json', 'application/geo+json'].includes(asset.mimeType)) throw new Error('Expected GeoJSON'); - const features = decodePackGeoJSON(asset.bytes); + return renderDecodedGeometry(decodePackGeoJSON(asset.bytes), pack, handle); +} + +export function renderDecodedGeometry(features, pack, handle) { const polygons = []; let lines; for (const feature of features) { diff --git a/src/scenes/dataPacks/navara.js b/src/scenes/dataPacks/navara.js new file mode 100644 index 000000000..bd8842cff --- /dev/null +++ b/src/scenes/dataPacks/navara.js @@ -0,0 +1,248 @@ +import * as Cesium from 'cesium'; +import { + BufferAttribute, + BufferGeometry, + Color, + DoubleSide, + Group, + Mesh, + MeshBasicMaterial, + Raycaster, + SRGBColorSpace, + Texture, +} from 'three'; +const KEY = 'gevScenePack', + registered = new WeakSet(); +const cyan = [0, 1, 1, 1], + xyz = (p) => Cesium.Cartesian3.fromDegrees(...p); +export function createNavaraPackRenderer(engine, { view, MeshDesc }) { + if (!registered.has(view)) { + view.registerMesh( + KEY, + class extends MeshDesc { + constructor(host, ctx, config) { + super(host, ctx, config); + this.object = config[KEY]; + } + getPassKey() { + return 'transparent'; + } + createMesh() { + return this.object.group; + } + onDestroy() { + this.object.dispose(); + super.onDestroy(); + } + }, + ); + registered.add(view); + } + const meshGroup = (handle) => { + const group = new Group(), + owned = []; + let released = false; + const dispose = () => { + if (released) return; + released = true; + for (const item of owned) item.dispose(); + }; + group.frustumCulled = false; + let holder; + try { + holder = view.addMesh({ [KEY]: { group, dispose } }); + } catch (e) { + dispose(); + throw e; + } + handle.own(() => { + try { + holder.delete(); + } finally { + dispose(); + } + }); + return { group, owned }; + }; + function addMesh(batch, shape, properties, id, texture) { + if (!shape) return; + const source = shape.attributes.position.values, + origin = shape.boundingSphere.center; + const coords = new Float32Array(source.length); + for (let i = 0; i < source.length; i += 3) { + coords[i] = source[i] - origin.x; + coords[i + 1] = source[i + 1] - origin.y; + coords[i + 2] = source[i + 2] - origin.z; + } + const geometry = new BufferGeometry(); + batch.owned.push(geometry); + geometry.setAttribute('position', new BufferAttribute(coords, 3)); + geometry.setIndex(new BufferAttribute(shape.indices, 1)); + if (shape.attributes.st) + geometry.setAttribute( + 'uv', + new BufferAttribute(new Float32Array(shape.attributes.st.values), 2), + ); + const material = new MeshBasicMaterial({ + ...properties, + map: texture, + side: DoubleSide, + transparent: true, + depthWrite: false, + toneMapped: false, + }); + batch.owned.push(material); + const mesh = new Mesh(geometry, material); + const pigment = material.color.clone(); + mesh.onBeforeRender = () => { + material.color + .copy(pigment) + .multiplyScalar(1 / Math.max(1, view.toneMappingExposure ?? 1)); + }; + mesh.position.set(origin.x, origin.y, origin.z); + mesh.userData.pickId = id; + batch.group.add(mesh); + return mesh; + } + return { + geometry(pack, features, handle) { + const points = features.filter((f) => f.type === 'Point'); + let bank; + if (points.length) { + bank = engine.movers.createSet({ + id: 'scene-pack-points-' + pack.id, + capacity: points.length, + kind: 'point', + depthTest: 'normal', + pickable: true, + point: { sizePx: 12, outlinePx: 2, outlineColor: [0, 0, 0, 1] }, + }); + handle.own(() => bank.destroy()); + } + let index = 0, + batch; + for (const f of features) { + const id = JSON.stringify([pack.id, f.id]); + handle.feature({ packId: pack.id, featureId: f.id }, id); + if (f.type === 'Point') { + bank.set(index++, { + position: xyz(f.coordinates), + color: cyan, + pickId: id, + show: true, + }); + } else if (f.type === 'LineString') { + const line = engine.lines.createLine({ + id, + widthPx: 4, + color: cyan, + pickId: id, + arc: 'geodesic', + }); + handle.own(() => line.destroy()); + line.setPositions(f.coordinates.map(xyz)); + } else { + batch ??= meshGroup(handle); + const polygon = new Cesium.PolygonGeometry({ + polygonHierarchy: new Cesium.PolygonHierarchy( + f.coordinates[0].map(xyz), + f.coordinates + .slice(1) + .map((r) => new Cesium.PolygonHierarchy(r.map(xyz))), + ), + perPositionHeight: true, + vertexFormat: Cesium.VertexFormat.POSITION_ONLY, + arcType: Cesium.ArcType.GEODESIC, + }); + addMesh( + batch, + Cesium.PolygonGeometry.createGeometry(polygon), + { color: new Color(0x00ffff), opacity: 0.35 }, + id, + null, + ); + } + } + if (bank) { + bank.setCount(index); + bank.commit(); + } + if (batch) { + const raycaster = new Raycaster(); + handle.own( + engine.picker.registerMeshPicker((at) => { + const ray = engine.camera.pickRay(at); + if (!ray) return []; + raycaster.ray.origin.copy(ray.origin); + raycaster.ray.direction.copy(ray.direction).normalize(); + batch.group.updateWorldMatrix(true, true); + const hits = raycaster.intersectObjects(batch.group.children); + const earth = engine.picker.pickGlobe(at); + const depth = earth + ? Cesium.Cartesian3.distance(ray.origin, earth) + : Infinity; + return hits + .filter((h) => h.distance <= depth + 1) + .map((h) => ({ + onTop: 0, + order: 0, + depth: h.distance, + result: { owner: 'scene-pack', id: h.object.userData.pickId }, + })); + }), + ); + } + engine.render.request('scene-pack'); + }, + image(pack, image, handle) { + const batch = meshGroup(handle); + const texture = new Texture(image); + texture.colorSpace = SRGBColorSpace; + texture.needsUpdate = true; + batch.owned.push(texture); + const shape = Cesium.RectangleGeometry.createGeometry( + new Cesium.RectangleGeometry({ + rectangle: Cesium.Rectangle.fromDegrees(...pack.placement.bounds), + height: pack.placement.height, + vertexFormat: Cesium.VertexFormat.POSITION_AND_ST, + }), + ); + addMesh(batch, shape, { color: 0xffffff, opacity: 1 }, null, texture); + engine.render.request('scene-pack'); + }, + media(pack, anchor, handle) { + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 256; + const ctx = canvas.getContext('2d'); + ctx.font = '14px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillStyle = 'white'; + ctx.fillText(pack.id, 128, 105, 250); + ctx.fillStyle = '#ff0'; + ctx.beginPath(); + ctx.arc(128, 128, 8, 0, Math.PI * 2); + ctx.fill(); + const set = engine.movers.createSet({ + id: 'scene-media-' + pack.id, + kind: 'sprite', + capacity: 1, + pickable: false, + depthTest: 'normal', + sprite: { + sizePx: 256, + atlas: { canvas, cells: { icon: [0, 0, 1, 1] } }, + }, + }); + handle.own(() => set.destroy()); + set.set(0, { + position: xyz([anchor.lon, anchor.lat, anchor.alt]), + cell: 'icon', + color: [1, 1, 1, 1], + show: true, + }); + set.setCount(1); + set.commit(); + }, + }; +} diff --git a/src/scenes/dataPacks/presentation.js b/src/scenes/dataPacks/presentation.js index ac4517a02..feb99b751 100644 --- a/src/scenes/dataPacks/presentation.js +++ b/src/scenes/dataPacks/presentation.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; -import { renderPackGeometry } from './geometry.js'; +import { renderDecodedGeometry } from './geometry.js'; +import { decodePackGeoJSON } from '../../director/packs/geojson.js'; import { useDataPacksStore } from '../../ui/stores/dataPacks.js'; import { applicationPinia } from '../../ui/vueRuntime.js'; @@ -18,15 +19,20 @@ let panelKeys = 0, export function createPackPresentations( viewer, targets = new Map(), - { store = useDataPacksStore(applicationPinia()) } = {}, + { + store = useDataPacksStore(applicationPinia()), + native = null, + container = viewer?.container, + } = {}, ) { const owner = {}; + const resources = new Set(); let key, cards = []; function present() { store.publish( owner, - cards.length ? { key, container: viewer.container, cards } : null, + cards.length ? { key, container, cards } : null, ); } function addCard(pack) { @@ -59,8 +65,31 @@ export function createPackPresentations( urls = []; const featureKeys = []; let card, + draw, destroyed = false; - return { + const nativeRemovers = []; + const handle = { + own(remove) { + nativeRemovers.push(remove); + }, + display(render) { + draw = render; + if (viewer || native) draw(native); + }, + redraw() { + if (!destroyed && (viewer || native)) draw?.(native); + }, + detach() { + for (const key of featureKeys.splice(0)) targets.delete(key); + for (const remove of nativeRemovers.splice(0).reverse()) remove(); + for (const entity of entities.splice(0)) viewer.entities.remove(entity); + for (const primitive of primitives.splice(0)) + viewer.scene.primitives.remove(primitive); + for (const material of materials.splice(0)) { + if (!material.isDestroyed()) material.destroy(); + } + viewer?.scene.requestRender(); + }, feature(id, pickedId) { const key = JSON.stringify([id.packId, id.featureId]); targets.set(key, pickedId); @@ -94,19 +123,14 @@ export function createPackPresentations( dispose() { if (destroyed) return; destroyed = true; - for (const key of featureKeys.splice(0)) targets.delete(key); - // The view stops a removed card's player as it goes. + handle.detach(); + resources.delete(handle); if (card) removeCard(card); - for (const entity of entities.splice(0)) viewer.entities.remove(entity); - for (const primitive of primitives.splice(0)) - viewer.scene.primitives.remove(primitive); - for (const material of materials.splice(0)) { - if (!material.isDestroyed()) material.destroy(); - } for (const url of urls.splice(0)) URL.revokeObjectURL(url); - viewer.scene.requestRender(); }, }; + resources.add(handle); + return handle; } function guarded(render) { return async (context) => { @@ -128,7 +152,26 @@ export function createPackPresentations( }; } return { - geojson: guarded(renderPackGeometry), + setScene(nextViewer, nextNative, nextContainer) { + for (const handle of resources) handle.detach(); + viewer = nextViewer; + native = nextNative; + if (nextContainer) container = nextContainer; + for (const handle of resources) handle.redraw(); + if (cards.length) present(); + }, + geojson: guarded(({ asset, pack }, handle) => { + if ( + !['application/json', 'application/geo+json'].includes(asset.mimeType) + ) + throw new Error('Expected GeoJSON'); + const features = decodePackGeoJSON(asset.bytes); + handle.display((native) => + native + ? native.geometry(pack, features, handle) + : renderDecodedGeometry(features, pack, handle), + ); + }), image: guarded(async ({ asset, pack, signal }, handle) => { if (asset.mimeType !== 'image/png') throw new Error('Expected PNG image'); const bytes = asset.bytes; @@ -162,7 +205,7 @@ export function createPackPresentations( signal.throwIfAborted(); if (image.naturalWidth * image.naturalHeight > 16 * 1024 * 1024) throw new Error('Image dimensions exceed limit'); - handle.primitive( + handle.display(native => native ? native.image(pack,image,handle) : handle.primitive( new Cesium.Primitive({ asynchronous: false, geometryInstances: new Cesium.GeometryInstance({ @@ -182,7 +225,7 @@ export function createPackPresentations( translucent: true, }), }), - ); + )); }), media: guarded(({ asset, pack, anchors }, handle) => { if ( @@ -200,7 +243,7 @@ export function createPackPresentations( (item) => item.id === pack.placement.anchorId, ); if (!anchor) throw new Error('Missing media anchor'); - handle.add({ + handle.display(native => native ? native.media(pack,anchor,handle) : handle.add({ name: pack.id, position: xyz([anchor.lon, anchor.lat, anchor.alt]), point: { pixelSize: 16, color: Cesium.Color.YELLOW }, @@ -209,7 +252,7 @@ export function createPackPresentations( font: '14px sans-serif', pixelOffset: new Cesium.Cartesian2(0, -24), }, - }); + })); handle.media({ kind: asset.mimeType.startsWith('video/') ? 'video' : 'audio', src: handle.url(new Blob([asset.bytes], { type: asset.mimeType })), diff --git a/src/scenes/director.js b/src/scenes/director.js index 320cabce4..ec1213e24 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -109,6 +109,7 @@ export class SceneDirector { this._bundleAssets = createBundleAssets(); this._dataPacks = createSceneDataPacks(viewer, { ...dataPacks, + container: engine?.host?.container ?? viewer?.container, sources: { ...dataPacks.sources, [BUNDLE_SOURCE]: this._bundleAssets.source, @@ -117,6 +118,7 @@ export class SceneDirector { this._sharing = createSceneSharing(this); this._interactionTransitions = 0; this._interactions = createSceneInteractions(viewer, { + engine, available: () => !this._destroyed && !this._running && !this.viewer?.trackedEntity, execute: (action, signal) => @@ -125,7 +127,7 @@ export class SceneDirector { this._cameraMotion = createCameraMotion({ applyPose: (pose) => this._setCameraView(pose), }); - const yieldCamera = (event) => { + this._yieldCamera = (event) => { // A settled feature click belongs to the action picker, not a new camera gesture. if ( event?.type === 'pointerdown' && @@ -135,15 +137,7 @@ export class SceneDirector { if (this._usesAuthoredCamera && !this._claimingCamera) this.stopScene(msg('Camera ownership changed')); }; - this._cameraHandoffUnsubscribe = - styleManager.subscribeCameraHandoff?.(yieldCamera); - const canvas = engine?.host.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); - }; + this._bindCameraInput(); /** @type {boolean} True while a scene run is in progress */ this._running = false; @@ -241,6 +235,91 @@ export class SceneDirector { }); } + _bindCameraInput() { + const { viewer, engine, styleManager } = this; + this._cameraHandoffUnsubscribe = styleManager.subscribeCameraHandoff?.( + this._yieldCamera, + ); + const canvas = engine?.host.canvas ?? viewer?.scene?.canvas; + for (const event of ['pointerdown', 'wheel']) + canvas?.addEventListener?.(event, this._yieldCamera, { passive: true }); + this._removeCameraInput = () => { + for (const event of ['pointerdown', 'wheel']) + canvas?.removeEventListener?.(event, this._yieldCamera); + }; + } + + suspendScene() { + if (this._destroyed || (this._sceneGate && !this.engine)) return; + this._sceneGate ??= new Promise((resolve) => { + this._resumeScene = resolve; + }); + this._cameraHandoffUnsubscribe?.(); + this._removeCameraInput?.(); + this._interactions.setScene(null, null); + this._dataPacks.setScene(null, null); + this._flight?.suspend(); + this.engine = this.viewer = null; + } + + attachScene(viewer, engine, styleManager, native, isAvailable) { + this.viewer = viewer; + this.engine = engine; + this.styleManager = styleManager; + this._isMapStackAvailable = isAvailable; + try { + this._dataPacks.setScene(viewer, native, engine.host.container); + this._interactions.setScene(viewer, engine, this._dataPacks.getTargets()); + } catch (error) { + // Release any packs already drawn before the caller parks the failed globe. + this.suspendScene(); + throw error; + } + } + + resumeScene() { + if (!this._sceneGate || !this.engine) return; + const resume = this._resumeScene; + this._sceneGate = this._resumeScene = null; + try { + this._bindCameraInput(); + if (this._running && this._previewRun) + this.styleManager.setRecordingMode(true, { + hidePanels: true, + hudMode: 'full', + safeFrame: '16:9', + }); + if (this._pendingPose) this._setCameraView(this._pendingPose); + this._pendingPose = null; + this._flight?.resume(); + } finally { + resume(); + } + } + + async _waitForScene(token) { + const signal = token?.signal; + while ( + this._sceneGate && + !this._destroyed && + !token?.cancelled && + !signal?.aborted + ) { + const gate = this._sceneGate; + if (!signal) await gate; + else + await new Promise((resolve) => { + const finish = () => { + signal.removeEventListener('abort', finish); + resolve(); + }; + signal.addEventListener('abort', finish, { once: true }); + gate.then(finish); + }); + } + return !this._destroyed && !token?.cancelled && !signal?.aborted; + } + _trackWork(promise) { this._pendingWork ||= new Set(); this._pendingWork.add(promise); @@ -253,6 +332,9 @@ export class SceneDirector { destroy() { if (this._destroyPromise) return this._destroyPromise; this._destroyed = true; + this._resumeScene?.(); + this._sceneGate = this._resumeScene = null; + this._flight?.finish(); this._bumpSceneSeekGeneration(); this._visibilityUnsubscribe?.(); this._cameraHandoffUnsubscribe?.(); @@ -1168,6 +1250,7 @@ export class SceneDirector { this._renderSceneSelect(); this._renderShotList(); + if (this._sceneGate && !(await this._waitForScene(token))) return; await this.styleManager.applyVisualState(this._visualStateForShot(shot), { isCurrent: () => !token.cancelled, }); @@ -1752,6 +1835,10 @@ export class SceneDirector { /** Place the camera at an authored replay start pose without an intermediate flight. */ _setCameraView(cameraState) { + if (cameraState && this._sceneGate) { + this._pendingPose = cameraState; + return true; + } const camera = this.engine?.camera; if (!cameraState || !camera) return false; this.styleManager?.clearSearchedLocation?.(); @@ -2063,6 +2150,8 @@ export class SceneDirector { /** Execute a validated action through the existing camera and layer admission paths. */ async _executeInteraction(action, signal) { + if (this._sceneGate && !(await this._waitForScene({ signal }))) + return false; if (signal.aborted || this._running || this._destroyed) return false; const { scene, shot } = this._getShot( this._selectedSceneId, @@ -2279,7 +2368,11 @@ export class SceneDirector { const applied = []; const refused = []; const abort = () => ({ applied, refused, cancelled: true }); - if (token?.cancelled) return abort(); + if ( + token?.cancelled || + (this._sceneGate && !(await this._waitForScene(token))) + ) + return abort(); // Deliberately NOT aborted: leaving an isolating mode IS the restore to // the operator's pre-mode state, which is exactly where a stopped scene @@ -2298,6 +2391,7 @@ export class SceneDirector { targetStates, registered, )) { + if (this._sceneGate && !(await this._waitForScene(token))) return abort(); let settled; if (params && typeof this.dataManager.restoreLayerState === 'function') { const outcome = await this.dataManager.restoreLayerState( @@ -2477,31 +2571,59 @@ export class SceneDirector { ); await new Promise((resolve) => { - // Guard against double-resolve from both callback and timeout - let done = false; - let timer; + const deadline = performance.now() + duration * 1000; + let timer, + generation = 0; const finish = () => { - if (done) return; - done = true; + if (this._flight !== flight) return; + this._flight = null; clearTimeout(timer); + token.signal?.removeEventListener('abort', finish); resolve(); }; - - this.engine.camera.flyTo({ - destination, - orientation: { - heading: geo.math.toRadians(cameraState.heading || 0), - pitch: geo.math.toRadians(cameraState.pitch ?? -35), - roll: geo.math.toRadians(cameraState.roll || 0), + const flight = { + finish, + suspend: () => { + generation++; + clearTimeout(timer); + this.engine?.camera.cancelFlight(); }, - duration, - easing: 'cubicInOut', - complete: finish, - cancel: finish, - }); - - // Safety timeout in case Cesium callbacks fail to fire - if (!done) timer = setTimeout(finish, (duration + 0.6) * 1000); + resume: () => { + if (this._sceneGate || this._flight !== flight) return; + if (!this.engine || token.cancelled) { + finish(); + return; + } + const current = ++generation; + const done = () => { + if (generation === current) finish(); + }; + const remaining = Math.max(0, (deadline - performance.now()) / 1000); + if (!remaining) { + this._setCameraView(cameraState); + finish(); + return; + } + this.engine.camera.flyTo({ + destination, + orientation: { + heading: geo.math.toRadians(cameraState.heading || 0), + pitch: geo.math.toRadians(cameraState.pitch ?? -35), + roll: geo.math.toRadians(cameraState.roll || 0), + }, + duration: remaining, + easing: 'cubicInOut', + complete: done, + cancel: done, + }); + if (this._flight === flight) + timer = setTimeout(done, (remaining + 0.6) * 1000); + }, + }; + this._flight?.finish(); + this._flight = flight; + token.signal?.addEventListener('abort', finish, { once: true }); + flight.resume(); }); } diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 9966f9887..4b4636a4d 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -2065,3 +2065,44 @@ test('a caught run failure resolves as run-failed, not success (#971)', async () ); } finally { console.warn = warn; await director.destroy(); restore(); } }); + +test('a failed scene-pack rebind releases the partial renderer and a stopped shot unblocks', async () => { + const director = Object.create(SceneDirector.prototype); + const drawn = new Set(); + director.engine = { host: { container: {} } }; + director._dataPacks = { + setScene(viewer) { + if (!viewer) drawn.clear(); + else { + drawn.add('point'); + throw new Error('could not attach polygon'); + } + }, + }; + director._interactions = { setScene() {} }; + director.suspendScene(); + const gate = director._sceneGate; + const abort = new AbortController(); + const stopped = director._waitForScene({ signal: abort.signal }); + assert.throws( + () => + director.attachScene( + {}, + { host: { container: {} } }, + {}, + null, + () => true, + ), + /could not attach polygon/, + ); + assert.equal(director.engine, null); + assert.equal(director._sceneGate, gate); + assert.equal(drawn.size, 0); + abort.abort(); + assert.equal(await stopped, false); + const waiting = director._waitForScene(); + director.engine = { host: {} }; + director.styleManager = {}; + director.resumeScene(); + assert.equal(await waiting, true); +}); diff --git a/src/scenes/interactions.js b/src/scenes/interactions.js index 57cf6b436..c6b0aec32 100644 --- a/src/scenes/interactions.js +++ b/src/scenes/interactions.js @@ -18,10 +18,12 @@ export function createSceneInteractions( viewer, { execute, + engine = null, available = () => true, store = useSceneInteractionsStore(applicationPinia()), }, ) { + let container = engine?.host?.container ?? viewer?.container; let panel = null, handler, items = [], @@ -76,10 +78,65 @@ export function createSceneInteractions( }); return ok; } + function bindInput(owner) { + if (!engine && !viewer) return; + const click = (event) => { + if ( + panel?.owner !== owner || + !shown() || + !isPointerFree() || + !available() + ) + return; + const picked = engine + ? event.picked?.id + : viewer.scene.pick(event.position)?.id; + const matches = items.filter( + (item) => + targets.get( + JSON.stringify([item.target.packId, item.target.featureId]), + ) === picked, + ); + if (!matches.length) return; + update({ + outlined: matches.map((item) => item.id), + status: t('Selected feature: {feature}. Choose an action.', { + feature: matches[0].target.featureId, + }), + }); + // Focus the first match once the panel shows the selection. + void nextTick(() => { + if (panel?.owner !== owner || !shown()) return; + const id = globalThis.CSS?.escape?.(matches[0].id) ?? matches[0].id; + (engine?.host.container ?? viewer?.container) + ?.querySelector?.( + `[data-director-interactions] [data-director-action="${id}"]`, + ) + ?.focus({ preventScroll: true }); + }); + }; + if (engine) { + handler = { destroy: engine.input.on('click', click, { pick: true }) }; + } else { + handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); + handler.setInputAction(click, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + } return { clear, destroy: clear, getState: session.getState, + setScene(nextViewer, nextEngine, registered) { + handler?.destroy(); + handler = null; + viewer = nextViewer; + engine = nextEngine; + if (registered) targets = registered; + if (!panel || (!engine && !viewer)) return; + container = engine?.host?.container ?? viewer?.container ?? container; + update({ container }); + bindInput(panel.owner); + }, activate(shot, registered) { clear(); items = shot.interactions || []; @@ -102,7 +159,7 @@ export function createSceneInteractions( panel = { id: ++activations, owner, - container: viewer.container, + container, items: items.map(({ id, label }) => ({ id, label })), status: t('Select a feature or use Tab and Enter to choose an action.'), busy: false, @@ -117,40 +174,7 @@ export function createSceneInteractions( }, }); session.activate(items); - handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - handler.setInputAction((event) => { - if ( - panel?.owner !== owner || - !shown() || - !isPointerFree() || - !available() - ) - return; - const picked = viewer.scene.pick(event.position)?.id; - const matches = items.filter( - (item) => - targets.get( - JSON.stringify([item.target.packId, item.target.featureId]), - ) === picked, - ); - if (!matches.length) return; - update({ - outlined: matches.map((item) => item.id), - status: t('Selected feature: {feature}. Choose an action.', { - feature: matches[0].target.featureId, - }), - }); - // Focus the first match once the panel shows the selection. - void nextTick(() => { - if (panel?.owner !== owner || !shown()) return; - const id = globalThis.CSS?.escape?.(matches[0].id) ?? matches[0].id; - viewer.container - ?.querySelector?.( - `[data-director-interactions] [data-director-action="${id}"]`, - ) - ?.focus({ preventScroll: true }); - }); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + bindInput(owner); }, }; } diff --git a/src/scenes/playbackAdapter.js b/src/scenes/playbackAdapter.js index b24785ac0..7d62f6862 100644 --- a/src/scenes/playbackAdapter.js +++ b/src/scenes/playbackAdapter.js @@ -5,7 +5,7 @@ import { displaySceneTitle, displayShotTitle } from './recipes.js'; export function createScenePlaybackAdapter(director, defaultShotDurationSec) { let timing; let travel; - return { + const phases = { async releaseScene(scene, token) { const released = await director._releaseSceneLayers(scene, token); if (!token || released) director._loadedSceneId = null; @@ -83,4 +83,18 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) { director._logEvent('scene_run_complete', {}); }, }; + // A shot boundary may fall while neither planet owns a canvas. Start + // the next phase on the incoming renderer, or settle immediately on Stop. + return Object.fromEntries( + Object.entries(phases).map(([key, run]) => [ + key, + async (...args) => { + if (director._sceneGate) { + const token = args[0]?.token ?? args[1]; + if (!(await director._waitForScene(token))) return; + } + return run(...args); + }, + ]), + ); } diff --git a/src/standalone/application.js b/src/standalone/application.js index a1f8b21e4..6ffbfcab1 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -140,6 +140,7 @@ export function createStandaloneApplication({ createTools: async (context) => { const tools = await createStandaloneTools({ ...context, + layerSession, loadingScreen, placeSearch, voice,