mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(director): add bounded scene data packs and placement
This commit is contained in:
1 parent
4436d7f18e
commit
92526e9a92
27 files changed
+1720
-28
No files matched your search
@@ -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 },
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
: [];
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user