mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 04:14:02 +00:00
Keep authored scene playback, pack media and geographic interactions across globes
This commit is contained in:
1 parent
a49671a391
commit
160e445677
18 files changed
+959
-101
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,? \};/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -140,6 +140,7 @@ export function createStandaloneApplication({
|
||||
createTools: async (context) => {
|
||||
const tools = await createStandaloneTools({
|
||||
...context,
|
||||
layerSession,
|
||||
loadingScreen,
|
||||
placeSearch,
|
||||
voice,
|
||||
|
||||
Reference in new issue
Block a user