Keep authored scene playback, pack media and geographic interactions across globes

This commit is contained in:
German Khokhlov committed 2026-11-03 04:49:46 +04:00
1 parent a49671a391
commit 160e445677
18 files changed
+959 -101

No files matched your search

+1
View File
@@ -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",
+314
View File
@@ -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();
}
+2 -2
View File
@@ -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,? \};/,
);
});
+13
View File
@@ -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);
+9 -2
View File
@@ -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({
+21 -6
View File
@@ -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) {
+2 -2
View File
@@ -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', () => {
+3 -1
View File
@@ -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;
}
+2
View File
@@ -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();
+10 -2
View File
@@ -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) {
+4 -1
View File
@@ -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) {
+248
View File
@@ -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();
},
};
}
+61 -18
View File
@@ -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 })),
+153 -31
View File
@@ -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();
});
}
+41
View File
@@ -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);
});
+59 -35
View File
@@ -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);
},
};
}
+15 -1
View File
@@ -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);
},
]),
);
}
+1
View File
@@ -140,6 +140,7 @@ export function createStandaloneApplication({
createTools: async (context) => {
const tools = await createStandaloneTools({
...context,
layerSession,
loadingScreen,
placeSearch,
voice,