feat(director): add bounded scene data packs and placement

This commit is contained in:
Sameh Khamis committed 2026-09-15 17:59:53 -07:00
1 parent 4436d7f18e
commit 92526e9a92
27 files changed
+1720 -28

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
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 = {} } = {}) {
const session = createDataPackSession({
sources,
adapters: createPackPresentations(viewer),
});
return {
...session,
apply(scene, shot, token) {
const wanted = new Set(shot.dataPackIds || []);
return session.load(
(scene.dataPacks || []).filter((pack) => wanted.has(pack.id)),
{ anchors: scene.anchors, signal: token?.signal },
);
},
};
}
+73
View File
@@ -0,0 +1,73 @@
import * as Cesium from 'cesium';
import { decodePackGeoJSON } from '../../director/packs/geojson.js';
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
/** Render a bounded geometry pack in owned batches; no asynchronous entity geometry or update loop. */
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);
const polygons = [];
let lines;
for (const feature of features) {
const id = { packId: pack.id, featureId: feature.id };
if (feature.type === 'Point')
handle.add({
name: `${pack.id} / ${feature.id}`,
position: xyz(feature.coordinates),
point: {
pixelSize: 12,
color: Cesium.Color.CYAN,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
},
});
else if (feature.type === 'LineString') {
if (!lines) {
lines = new Cesium.PolylineCollection();
handle.primitive(lines);
}
lines.add({
id,
positions: feature.coordinates.map(xyz),
width: 4,
material: Cesium.Material.fromType('Color', {
color: Cesium.Color.CYAN,
}),
});
} else
polygons.push(
new Cesium.GeometryInstance({
id,
geometry: new Cesium.PolygonGeometry({
polygonHierarchy: new Cesium.PolygonHierarchy(
feature.coordinates[0].map(xyz),
feature.coordinates
.slice(1)
.map((ring) => new Cesium.PolygonHierarchy(ring.map(xyz))),
),
perPositionHeight: true,
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
arcType: Cesium.ArcType.GEODESIC,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
Cesium.Color.CYAN.withAlpha(0.35),
),
},
}),
);
}
if (polygons.length)
handle.primitive(
new Cesium.Primitive({
asynchronous: false,
geometryInstances: polygons,
appearance: new Cesium.PerInstanceColorAppearance({
translucent: true,
flat: true,
closed: false,
}),
}),
);
}
+217
View File
@@ -0,0 +1,217 @@
import * as Cesium from 'cesium';
import { renderPackGeometry } from './geometry.js';
function caption(viewer, pack) {
const card = document.createElement('section');
card.dataset.directorPack = pack.id;
Object.assign(card.style, {
color: '#e3faff',
background: '#08141eed',
border: '1px solid #2491a8',
padding: '8px',
margin: '4px',
maxWidth: '260px',
font: '12px sans-serif',
});
const title = document.createElement('div');
title.textContent = `${pack.id} · ${pack.attribution.text} · ${pack.attribution.license}`;
card.append(title);
if (pack.attribution.url) {
const link = document.createElement('a');
link.textContent = 'Source';
link.href = pack.attribution.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.style.color = '#6eeaff';
card.append(link);
}
return card;
}
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
/** Create rendering adapters; data acquisition and authored placement stay independent. */
export function createPackPresentations(viewer) {
let panel;
function addCard(pack) {
if (!panel) {
panel = document.createElement('div');
panel.dataset.directorPacks = '';
Object.assign(panel.style, {
position: 'absolute',
right: '10px',
bottom: '70px',
maxHeight: '40vh',
overflowY: 'auto',
zIndex: '30',
});
viewer.container.append(panel);
}
const card = caption(viewer, pack);
panel.append(card);
return card;
}
function resource(pack) {
const entities = [],
primitives = [],
materials = [],
urls = [];
let card,
destroyed = false;
return {
add(spec) {
const entity = viewer.entities.add(spec);
entities.push(entity);
viewer.scene.requestRender();
return entity;
},
primitive(value) {
primitives.push(viewer.scene.primitives.add(value));
if (value.appearance?.material)
materials.push(value.appearance.material);
viewer.scene.requestRender();
},
card() {
return (card ||= addCard(pack));
},
url(blob) {
const url = URL.createObjectURL(blob);
urls.push(url);
return url;
},
dispose() {
if (destroyed) return;
destroyed = true;
for (const media of card?.querySelectorAll('video,audio') || []) {
media.pause();
media.removeAttribute('src');
media.load();
}
card?.remove();
if (panel && !panel.childElementCount) {
panel.remove();
panel = null;
}
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();
},
};
}
function guarded(render) {
return async (context) => {
const handle = resource(context.pack);
const abort = () => handle.dispose();
context.signal.addEventListener('abort', abort, { once: true });
try {
context.signal.throwIfAborted();
await render(context, handle);
context.signal.throwIfAborted();
handle.card();
return handle;
} catch (error) {
handle.dispose();
throw error;
} finally {
context.signal.removeEventListener('abort', abort);
}
};
}
return {
geojson: guarded(renderPackGeometry),
image: guarded(async ({ asset, pack, signal }, handle) => {
if (asset.mimeType !== 'image/png') throw new Error('Expected PNG image');
const bytes = asset.bytes;
if (
bytes.length < 24 ||
![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82].every(
(v, i) => bytes[i] === v,
)
)
throw new Error('Invalid PNG header');
const header = new DataView(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength,
);
const width = header.getUint32(16),
height = header.getUint32(20);
if (!width || !height || width > 4096 || height > 4096)
throw new Error('Image dimensions exceed limit');
const image = new Image();
image.src = handle.url(new Blob([asset.bytes], { type: asset.mimeType }));
const abort = () => {
image.src = '';
};
signal.addEventListener('abort', abort, { once: true });
try {
await image.decode();
} finally {
signal.removeEventListener('abort', abort);
}
signal.throwIfAborted();
if (image.naturalWidth * image.naturalHeight > 16 * 1024 * 1024)
throw new Error('Image dimensions exceed limit');
handle.primitive(
new Cesium.Primitive({
asynchronous: false,
geometryInstances: new Cesium.GeometryInstance({
id: { packId: pack.id },
geometry: new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(...pack.placement.bounds),
height: pack.placement.height,
vertexFormat:
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
}),
}),
appearance: new Cesium.MaterialAppearance({
material: Cesium.Material.fromType('Image', { image }),
materialSupport: Cesium.MaterialAppearance.MaterialSupport.TEXTURED,
flat: true,
faceForward: true,
translucent: true,
}),
}),
);
}),
media: guarded(({ asset, pack, anchors }, handle) => {
if (
![
'video/mp4',
'video/webm',
'audio/mpeg',
'audio/ogg',
'audio/wav',
'audio/webm',
].includes(asset.mimeType)
)
throw new Error('Unsupported media');
const anchor = anchors.find(
(item) => item.id === pack.placement.anchorId,
);
if (!anchor) throw new Error('Missing media anchor');
handle.add({
name: pack.id,
position: xyz([anchor.lon, anchor.lat, anchor.alt]),
point: { pixelSize: 16, color: Cesium.Color.YELLOW },
label: {
text: pack.id,
font: '14px sans-serif',
pixelOffset: new Cesium.Cartesian2(0, -24),
},
});
const media = document.createElement(
asset.mimeType.startsWith('video/') ? 'video' : 'audio',
);
media.controls = true;
media.preload = 'none';
media.style.width = '240px';
media.src = handle.url(new Blob([asset.bytes], { type: asset.mimeType }));
handle.card().append(media);
}),
};
}
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPackPresentations } from './presentation.js';
test('abort releases an in-flight image URL before the decoder settles or the viewer is destroyed', async () => {
let settle;
const pending = new Promise((resolve) => {
settle = resolve;
});
const priorImage = globalThis.Image;
const priorRevoke = URL.revokeObjectURL;
const revoked = [];
globalThis.Image = class {
decode() {
return pending;
}
};
URL.revokeObjectURL = (url) => {
revoked.push(url);
priorRevoke(url);
};
const viewer = {
scene: { requestRender() {} },
entities: {
remove() {
assert.fail('No entity should be acquired');
},
},
};
const signalOwner = new AbortController();
const bytes = new Uint8Array(24);
bytes.set([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]);
const header = new DataView(bytes.buffer);
header.setUint32(16, 8);
header.setUint32(20, 8);
try {
const rendering = createPackPresentations(viewer).image({
pack: {},
asset: { bytes, mimeType: 'image/png' },
signal: signalOwner.signal,
});
const rejected = assert.rejects(rendering, /abort/i);
signalOwner.abort();
assert.equal(
revoked.length,
1,
'object URL must be released synchronously with cancellation',
);
settle();
await rejected;
assert.equal(revoked.length, 1, 'late decode cannot dispose twice');
} finally {
settle();
globalThis.Image = priorImage;
URL.revokeObjectURL = priorRevoke;
}
});
+26
View File
@@ -25,6 +25,7 @@ import {
getSceneAppendRecipeById,
} from './recipes.js';
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
import { createSceneDataPacks } from './dataPacks/controller.js';
import { createDefaultScenePacks } from './packs/defaults.js';
import {
layerStatesForShot,
@@ -77,6 +78,7 @@ export class SceneDirector {
{
isMapStackAvailable = () => false,
scenePacks = createDefaultScenePacks(),
dataPacks = {},
} = {},
) {
this._destroyed = false;
@@ -86,6 +88,7 @@ export class SceneDirector {
this.dataManager = dataManager;
this._isMapStackAvailable = isMapStackAvailable;
this._scenePacks = scenePacks;
this._dataPacks = createSceneDataPacks(viewer, dataPacks);
this._cameraMotion = createCameraMotion({
applyPose: (pose) => this._setCameraView(pose),
});
@@ -200,6 +203,7 @@ export class SceneDirector {
this._cameraHandoffUnsubscribe?.();
this._removeCameraInput?.();
this._cameraMotion?.destroy();
this._dataPacks?.destroy();
this._controls?.destroy();
this._state.destroy();
this._destroyPromise = Promise.resolve().then(async () => {
@@ -997,6 +1001,7 @@ export class SceneDirector {
// manager) rather than merely ignored once it has already committed.
this._cancelActiveSceneTravel();
this._usesAuthoredCamera = !!shot.move;
this._dataPacks?.clear();
this._loadAbort?.abort();
const controller = new AbortController();
this._loadAbort = controller;
@@ -1041,6 +1046,9 @@ export class SceneDirector {
if (this._loadAbort === controller) this._loadAbort = null;
return { started: false, reason: 'layers-refused' };
}
if (!(await this._applyDataPacks(scene, shot, token)))
return { started: false, reason: 'data-packs-unavailable' };
if (token.cancelled) return;
if (seekState) {
this._setCameraView(
seekState.camera || resolveCameraPose(scene, shot.camera),
@@ -1613,6 +1621,7 @@ export class SceneDirector {
// Aborting cancels a layer transition already in flight; bumping the
// generation disowns everything the load has not yet started.
this._cancelActiveSceneTravel();
this._dataPacks?.clear();
this._loadAbort?.abort();
this._loadAbort = null;
this._loadGeneration++;
@@ -1723,6 +1732,7 @@ export class SceneDirector {
* @param {string} [reason='Stopped'] - Human-readable cancellation reason
*/
stopScene(reason = 'Stopped') {
this._dataPacks?.clear();
this._sceneSeekGeneration++;
this._loadAbort?.abort();
this._loadAbort = null;
@@ -1738,6 +1748,21 @@ export class SceneDirector {
this._logEvent('scene_stopped', { reason });
}
/** Apply only the shot's declared packs through registered sources. */
async _applyDataPacks(scene, shot, token) {
try {
return (await this._dataPacks?.apply(scene, shot, token)) ?? true;
} catch (error) {
if (!token?.cancelled) this._updateStatus(error.message);
return false;
}
}
/** Copied resource state for lifecycle checks and diagnostics. */
getDataPackState() {
return this._dataPacks?.getState() || { status: 'idle', count: 0 };
}
/** Export the entire project as a timestamped JSON file download. */
exportProject() {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -1912,6 +1937,7 @@ export class SceneDirector {
* @returns {Promise<boolean>} True only when every owned layer is released
*/
async _releaseSceneLayers(scene, token = null) {
this._dataPacks?.clear();
const layerIds = Array.isArray(scene?.releaseLayerIds)
? scene.releaseLayerIds
: [];
+1 -1
View File
@@ -1443,7 +1443,7 @@ test('unsupported stored documents cannot be overwritten by fallback edits', asy
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) });
assert.equal(director._storageReadError, null);
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 4);
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 5);
} finally { restore(); }
});
+9 -2
View File
@@ -29,11 +29,18 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
{ isCurrent: () => !token.cancelled && !token.signal?.aborted },
);
},
applyLayers({ scene, shot, token }) {
return director._applyLayerStates(
async applyLayers({ scene, shot, token }) {
const result = await director._applyLayerStates(
director._layerStatesForShot(scene, shot),
token,
);
if (
!token.cancelled &&
!result.refused.length &&
!(await director._applyDataPacks(scene, shot, token))
)
throw new Error('Data packs unavailable');
return result;
},
travel({ scene, shot, token }) {
director._loadedSceneId = scene.id;
+2
View File
@@ -253,6 +253,7 @@ export function normalizeShot(
? { altitudeReference: camera.altitudeReference || 'ellipsoid' }
: {}),
},
...(rawShot?.dataPackIds ? { dataPackIds: [...rawShot.dataPackIds] } : {}),
...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}),
visual: {
style: visual.style || 'normal',
@@ -346,6 +347,7 @@ export function normalizeProject(rawProject) {
return {
id: scene?.id || uid('scene'),
title: scene?.title || `Scene ${sceneIdx + 1}`,
...(scene?.dataPacks ? { dataPacks: deepClone(scene.dataPacks) } : {}),
...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}),
releaseLayerIds: [
...new Set(