diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d29775e6..58a5bb48b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -199,6 +199,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
+- Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution.
+
+
- Give application request services, terrain/floor caches and annotation lookup state explicit owners and cancellation; share them across controls, layers and voice.
- Construct application layers from explicit sources, with standalone provider selection and catalog-owned aircraft classification; controls and voice queries use those instances.
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 91a1c1871..7535b8693 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1,5 +1,12 @@
# God's Eye View Current State
+Director version-6 documents add bounded, scene-local feature actions. Settled
+LOAD/seek exposes keyboard-accessible text/source cards, anchor focus, explicit
+shot transitions and admitted layer state changes. Pointer claims take priority;
+Stop/replacement/teardown cancel pending actions and release handlers and UI.
+Same-shot seeks rebuild selected packs and restore declared layer baselines.
+See [scene actions](DIRECTOR-INTERACTIONS.md). Existing scenes/assets and credit remain.
+
Director version-5 documents add scene-local data-pack manifests and per-shot
selection. Registered sources acquire bounded, cancellable assets separately from
GeoJSON, PNG and manually played media presentation. Storage paths, attribution
diff --git a/docs/DIRECTOR-INTERACTIONS.md b/docs/DIRECTOR-INTERACTIONS.md
new file mode 100644
index 000000000..740fe808e
--- /dev/null
+++ b/docs/DIRECTOR-INTERACTIONS.md
@@ -0,0 +1,67 @@
+# Director scene actions
+
+Version-6 scene documents can attach actions to stable GeoJSON feature IDs in a
+shot's selected data packs. Existing versions 1–5 migrate without adding actions.
+Import validates data and references; it does not load assets or execute actions.
+
+Add `interactions` to a shot, for example:
+
+```json
+{
+ "id": "read-outline",
+ "label": "About this outline",
+ "target": { "packId": "outline", "featureId": "boundary" },
+ "action": {
+ "type": "card",
+ "text": "An authored explanation, displayed as plain text.",
+ "url": "https://example.org/source"
+ }
+}
+```
+
+The target must be a feature in a GeoJSON pack selected by that shot. Points,
+lines and polygons retain stable pack/feature identities. Assets load before
+feature existence is checked; a missing feature refuses the entire action set.
+Images and native media are not interaction targets in this version.
+
+| Action | Fields | Behavior |
+| --- | --- | --- |
+| `card` | `text`, optional `url` | Show plain text and an optional HTTPS source link; never HTML |
+| `focus` | `anchorId` | Set a top-down camera pose at a scene anchor through camera ownership arbitration |
+| `shot` | `shotId` | Load another shot in the same scene at its authored start |
+| `layer` | `layerId`, boolean `enabled` | Request an explicit state through the ordinary layer admission path |
+
+There are at most 64 interactions per shot. Card text is limited to 4,096
+characters; source links cannot contain credentials, query strings or fragments.
+No script, module, arbitrary fetch endpoint or request header is accepted.
+
+Actions appear after LOAD or seek finishes. Click a pack feature to select its
+actions and focus the first button; choose a button explicitly to execute it.
+Tab traverses buttons and Enter/Space activates them. The selected buttons have
+visible outlines, and status feedback is announced to assistive technology.
+There are no global keyboard shortcuts to collide with navigation or typing.
+Drawing ownership blocks both pointer selection and action execution. Camera
+focus uses existing navigation admission, including Cockpit restrictions.
+
+Actions are inactive during automatic playback and camera travel. They do not
+introduce automatic branching or loops. A chain of user-triggered shot changes
+is limited to 64 transitions; an explicit LOAD starts a new chain. Import/Stop,
+replacement, playback and teardown remove selection, cards and picking handlers,
+and abort pending layer requests. No per-frame interaction loop runs.
+
+A layer action must name a layer with an explicit baseline in the current shot.
+A target shot must declare a baseline for every layer the departing shot can
+change. LOAD and seek reapply those baselines and clear selection. Stop cancels
+pending transitions; already completed layer changes remain visible, consistent
+with ordinary scene controls. Camera focus is an immediate anchor pose, not a
+new timeline segment; seek restores the authored camera pose. Scene JSON is not
+modified by executing an action.
+
+The portable interaction session owns action admission/cancellation. The scene
+interaction owner supplies accessible DOM and picking; pack renderers own the
+feature identities and release them with their geometry. Layer/source admission
+and camera arbitration remain with their existing owners.
+
+Visual authoring, import previews and share bundles remain separate follow-up
+work. For now, author actions in scene JSON and use the existing import/export
+controls. Existing content, assets, notices and contributor credit are preserved.
diff --git a/docs/DIRECTOR.md b/docs/DIRECTOR.md
index 2ba452b7d..bf37c91bb 100644
--- a/docs/DIRECTOR.md
+++ b/docs/DIRECTOR.md
@@ -105,3 +105,5 @@ arbitration, layer cleanup, media timing and provider fallback. Acceptance inclu
portable runner tests, existing Director regressions and browser playback with
Stop/seek/replay, plus tracking and voice checks. Use reproducible local fixtures
for failures and cancellation, alongside live visual checks for actual content.
+
+Declarative actions are implemented in version 6; see [scene actions](DIRECTOR-INTERACTIONS.md) for supported targets, execution and reset semantics.
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 3374b88fc..1304f2901 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -739,5 +739,7 @@
"scripts/qa-director-camera.mjs",
"src/director/packs/packs.test.mjs",
"scripts/qa-director-packs.mjs",
- "src/scenes/dataPacks/presentation.test.mjs"
+ "src/scenes/dataPacks/presentation.test.mjs",
+ "src/director/interactions/interactions.test.mjs",
+ "scripts/qa-director-interactions.mjs"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 8147f85e4..002e478ba 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -625,7 +625,9 @@
"src/director/packs/source.js",
"src/director/packs/session.js",
"src/director/packs/geojson.js",
- "src/director/packs/manifest.js"
+ "src/director/packs/manifest.js",
+ "src/director/interactions/document.js",
+ "src/director/interactions/session.js"
],
"external": []
},
@@ -666,7 +668,11 @@
"src/director/packs/manifest.js",
"src/scenes/dataPacks/controller.js",
"src/scenes/dataPacks/presentation.js",
- "src/scenes/dataPacks/geometry.js"
+ "src/scenes/dataPacks/geometry.js",
+ "src/director/interactions/document.js",
+ "src/director/interactions/session.js",
+ "src/scenes/interactions.js",
+ "src/data/inputOwnership.js"
],
"external": ["cesium"]
},
@@ -1784,7 +1790,10 @@
"src/director/packs/manifest.js",
"src/scenes/dataPacks/controller.js",
"src/scenes/dataPacks/presentation.js",
- "src/scenes/dataPacks/geometry.js"
+ "src/scenes/dataPacks/geometry.js",
+ "src/director/interactions/document.js",
+ "src/director/interactions/session.js",
+ "src/scenes/interactions.js"
],
"external": [
"@mapbox/vector-tile",
diff --git a/scripts/qa-director-interactions.mjs b/scripts/qa-director-interactions.mjs
new file mode 100644
index 000000000..6c04f4b80
--- /dev/null
+++ b/scripts/qa-director-interactions.mjs
@@ -0,0 +1,364 @@
+#!/usr/bin/env node
+/** Real pointer/keyboard acceptance using a synthetic, self-authored feature pack. */
+import fs from 'node:fs';
+import puppeteer from 'puppeteer';
+const output = 'qa-shots/director-interactions';
+fs.mkdirSync(output, { recursive: true });
+const browser = await puppeteer.launch({
+ headless: true,
+ args: ['--no-sandbox', '--disable-dev-shm-usage'],
+});
+const page = await browser.newPage();
+let failures = 0;
+const errors = [];
+const check = (name, ok) => {
+ console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
+ if (!ok) failures++;
+};
+page.on('pageerror', (e) => errors.push(e.message));
+await page.setRequestInterception(true);
+page.on('request', (request) => {
+ if (new URL(request.url()).pathname === '/scene-assets/qa/actions.json')
+ void request
+ .respond({
+ status: 200,
+ contentType: 'application/geo+json',
+ body: JSON.stringify({
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'point',
+ geometry: { type: 'Point', coordinates: [-97.742, 30.2672, 600] },
+ },
+ ],
+ }),
+ })
+ .catch(() => {});
+ else void request.continue().catch(() => {});
+});
+const action = (id) => `[data-director-action="${id}"]`;
+const load = () =>
+ page.evaluate(() =>
+ window.__godsEyeView.sceneDirector.loadShot('interactive', 'one', {
+ flyDuration: 0.2,
+ }),
+ );
+try {
+ await page.setViewport({ width: 1440, height: 900 });
+ await page.goto(
+ `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
+ { waitUntil: 'domcontentloaded' },
+ );
+ await page.waitForFunction(
+ () =>
+ window.__godsEyeView?.sceneDirector &&
+ document.getElementById('loading-screen')?.classList.contains('hidden'),
+ { timeout: 60000 },
+ );
+ await page.evaluate(async () => {
+ const d = window.__godsEyeView.sceneDirector;
+ const camera = {
+ lat: 30.2672,
+ lon: -97.742,
+ alt: 4000,
+ heading: 0,
+ pitch: -90,
+ roll: 0,
+ altitudeReference: 'ellipsoid',
+ };
+ const target = { packId: 'data', featureId: 'point' };
+ const interaction = (id, label, action) => ({ id, label, target, action });
+ window.__interactionProject = {
+ version: 6,
+ scenes: [
+ {
+ id: 'interactive',
+ title: 'Interactive fixture',
+ anchors: [
+ {
+ id: 'view',
+ lat: 30.27,
+ lon: -97.74,
+ alt: 5000,
+ altitudeReference: 'ellipsoid',
+ },
+ ],
+ dataPacks: [
+ {
+ id: 'data',
+ version: 1,
+ format: 'geojson',
+ source: { adapter: 'assets', path: 'qa/actions.json' },
+ attribution: { text: 'Synthetic fixture', license: 'CC0-1.0' },
+ placement: { altitudeReference: 'ellipsoid' },
+ },
+ ],
+ shots: [
+ {
+ id: 'one',
+ title: 'Choose an action',
+ camera,
+ durationSec: 0.2,
+ holdSec: 0,
+ move: {
+ from: { ...camera, alt: 4100, altitudeReference: 'ellipsoid' },
+ easing: 'linear',
+ },
+ visual: { style: 'normal', mapStack: 'photoreal' },
+ layers: { traffic: false },
+ dataPackIds: ['data'],
+ interactions: [
+ interaction('card', 'Read source', {
+ type: 'card',
+ text: 'Plain text only',
+ url: 'https://example.org/source',
+ }),
+ interaction('focus', 'Focus anchor', {
+ type: 'focus',
+ anchorId: 'view',
+ }),
+ interaction('layer', 'Show traffic', {
+ type: 'layer',
+ layerId: 'traffic',
+ enabled: true,
+ }),
+ interaction('next', 'Next shot', {
+ type: 'shot',
+ shotId: 'two',
+ }),
+ ],
+ },
+ {
+ id: 'two',
+ title: 'Next',
+ camera: { ...camera, heading: 30 },
+ durationSec: 0.2,
+ holdSec: 0,
+ layers: { traffic: false },
+ },
+ ],
+ },
+ ],
+ };
+ await d.importProjectFile(
+ new File([JSON.stringify(window.__interactionProject)], 'actions.json'),
+ );
+ });
+ check(
+ 'import installs no action handlers or UI',
+ await page.evaluate(
+ () =>
+ !window.__godsEyeView.sceneDirector.getInteractionState().active &&
+ !document.querySelector('[data-director-interactions]'),
+ ),
+ );
+ await load();
+ check(
+ 'settled LOAD activates four actions',
+ await page.evaluate(
+ () =>
+ window.__godsEyeView.sceneDirector.getInteractionState().count === 4,
+ ),
+ );
+ await page.waitForFunction(
+ () => window.__godsEyeView.tileset?.tilesLoaded === true,
+ { timeout: 60000 },
+ );
+ await new Promise((r) => setTimeout(r, 2000));
+ const point = await page.evaluate(async () => {
+ const d = window.__godsEyeView.sceneDirector;
+ const entity = d.viewer.entities.values.find(
+ (e) => e.name === 'data / point',
+ );
+ const pos = entity.position.getValue(d.viewer.clock.currentTime);
+ const screen = d.viewer.scene.cartesianToCanvasCoordinates(pos);
+ const rect = d.viewer.scene.canvas.getBoundingClientRect();
+ return { x: screen.x + rect.left, y: screen.y + rect.top };
+ });
+ await page.mouse.click(point.x, point.y);
+ check(
+ 'real globe pick selects the feature and focuses its keyboard action',
+ await page.evaluate(
+ () =>
+ document.activeElement?.dataset.directorAction === 'card' &&
+ document
+ .querySelector('[data-director-interactions] [role=status]')
+ .textContent.includes('point'),
+ ),
+ );
+ await page.keyboard.press('Enter');
+ check(
+ 'Enter shows literal text and a safe source link',
+ await page.evaluate(() => {
+ const card = document.querySelector('[data-director-action-card]');
+ return (
+ card?.textContent.includes('Plain text only') &&
+ !card.querySelector('b') &&
+ card.querySelector('a')?.rel === 'noopener noreferrer'
+ );
+ }),
+ );
+ await page.screenshot({ path: `${output}/selected.png` });
+ fs.writeFileSync(
+ `${output}/report.json`,
+ JSON.stringify(
+ await page.evaluate(() => ({
+ tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
+ })),
+ null,
+ 2,
+ ),
+ );
+ await page.keyboard.press('Tab');
+ await page.keyboard.press('Space');
+ check(
+ 'Tab/Space focuses the authored anchor through camera ownership',
+ await page.evaluate(
+ () =>
+ Math.abs(
+ window.__godsEyeView.sceneDirector.viewer.camera.positionCartographic
+ .height - 5000,
+ ) < 1,
+ ),
+ );
+ await page.screenshot({ path: `${output}/anchor.png` });
+ const sourceBase = process.env.QA_SOURCE_BASE || '/src';
+ // Resolve ownership from the same module graph as the active application.
+ await page.evaluate(async (base) => {
+ window.__inputOwner = await import(`${base}/data/inputOwnership.js`);
+ window.__lease = window.__inputOwner.claimPointer('qa-draw');
+ }, sourceBase);
+ await page.click(action('card'));
+ check(
+ 'drawing ownership refuses actions',
+ await page.evaluate(() =>
+ document
+ .querySelector('[data-director-interactions] [role=status]')
+ .textContent.includes('unavailable'),
+ ),
+ );
+ await page.evaluate(() => window.__inputOwner.releasePointer(window.__lease));
+ // Hold the actual manager admission call to exercise Stop before an enable settles.
+ await page.evaluate(() => {
+ const d = window.__godsEyeView.sceneDirector,
+ m = d.dataManager;
+ window.__originalEnable = m.setEnabled;
+ m.setEnabled = function (id, enabled, options) {
+ if (id !== 'traffic' || !enabled)
+ return window.__originalEnable.call(this, id, enabled, options);
+ window.__actionSignal = options.signal;
+ return new Promise((resolve) => {
+ window.__lateAction = () => resolve(true);
+ options.signal.addEventListener('abort', () => resolve(false), {
+ once: true,
+ });
+ });
+ };
+ });
+ await page.click(action('layer'));
+ await page.waitForFunction(() => window.__actionSignal);
+ await page.evaluate(() => window.__godsEyeView.sceneDirector.stopScene());
+ check(
+ 'Stop aborts pending layer admission and removes handlers/cards/selection',
+ await page.evaluate(
+ () =>
+ window.__actionSignal.aborted &&
+ !window.__godsEyeView.sceneDirector.getInteractionState().active &&
+ !document.querySelector('[data-director-interactions]'),
+ ),
+ );
+ await page.evaluate(() => {
+ window.__lateAction();
+ window.__godsEyeView.sceneDirector.dataManager.setEnabled =
+ window.__originalEnable;
+ });
+ await load();
+ await page.evaluate(() =>
+ window.__godsEyeView.sceneDirector.seekScene('interactive', 0.1),
+ );
+ check(
+ 'same-shot seek restores pack geometry and resets action state',
+ await page.evaluate(
+ () =>
+ window.__godsEyeView.sceneDirector.getInteractionState().count === 4 &&
+ window.__godsEyeView.sceneDirector.getInteractionState().selected ===
+ null &&
+ window.__godsEyeView.sceneDirector.getDataPackState().count === 1,
+ ),
+ );
+ await page.evaluate(() => {
+ window.__oldActionButton = document.querySelector(
+ '[data-director-action=card]',
+ );
+ });
+ await page.click(action('next'));
+ await page.waitForFunction(
+ () => window.__godsEyeView.sceneDirector._selectedShotId === 'two',
+ );
+ check(
+ 'explicit shot transition replaces resources and removes actions',
+ await page.evaluate(
+ () =>
+ !window.__godsEyeView.sceneDirector.getInteractionState().active &&
+ window.__godsEyeView.sceneDirector.getDataPackState().count === 0,
+ ),
+ );
+ await load();
+ await page.evaluate(() => window.__oldActionButton.click());
+ check(
+ 'detached buttons cannot invoke replacement actions with the same ID',
+ await page.evaluate(
+ () =>
+ window.__godsEyeView.sceneDirector.getInteractionState().selected ===
+ null &&
+ !document.querySelector('[data-director-action-card]').textContent,
+ ),
+ );
+ await page.evaluate(() => {
+ window.__godsEyeView.sceneDirector._interactionTransitions = 64;
+ });
+ await page.click(action('next'));
+ check(
+ 'bounded transition chain cannot take another branch',
+ await page.evaluate(
+ () => window.__godsEyeView.sceneDirector._selectedShotId === 'one',
+ ),
+ );
+ await load();
+ await page.evaluate(async () => {
+ const p = structuredClone(window.__interactionProject);
+ p.scenes[0].shots[0].interactions[0].target.featureId = 'missing';
+ const d = window.__godsEyeView.sceneDirector;
+ await d.importProjectFile(new File([JSON.stringify(p)], 'missing.json'));
+ await d.loadShot('interactive', 'one', { flyDuration: 0.2 });
+ });
+ check(
+ 'unknown loaded feature refuses the whole action set',
+ await page.evaluate(
+ () =>
+ !window.__godsEyeView.sceneDirector.getInteractionState().active &&
+ !document.querySelector('[data-director-interactions]'),
+ ),
+ );
+ await page.evaluate(async () => {
+ const d = window.__godsEyeView.sceneDirector;
+ await d.importProjectFile(
+ new File([JSON.stringify(window.__interactionProject)], 'restore.json'),
+ );
+ await d.loadShot('interactive', 'one', { flyDuration: 0.2 });
+ await d.destroy();
+ });
+ check(
+ 'teardown releases interactive resources',
+ await page.evaluate(
+ () =>
+ window.__godsEyeView.sceneDirector.getInteractionState().count === 0 &&
+ !document.querySelector('[data-director-interactions]'),
+ ),
+ );
+ check('no uncaught browser errors', errors.length === 0);
+} finally {
+ await browser.close();
+}
+process.exitCode = failures ? 1 : 0;
diff --git a/src/director/camera.test.mjs b/src/director/camera.test.mjs
index f3ecb0fbc..cf7c43bb0 100644
--- a/src/director/camera.test.mjs
+++ b/src/director/camera.test.mjs
@@ -73,7 +73,7 @@ test('v3 unversioned bloom already uses scale 2 and is not migrated again in lat
}),
),
);
- assert.equal(project.version, 5);
+ assert.equal(project.version, 6);
assert.equal(project.scenes[0].shots[0].visual.bloom.intensity, 50);
});
test('move endpoints, easing, shortest arcs and hold agree with scene seeking', () => {
diff --git a/src/director/document.js b/src/director/document.js
index 0def9ff4d..48804d83b 100644
--- a/src/director/document.js
+++ b/src/director/document.js
@@ -15,7 +15,8 @@ import {
import { validateSceneDataPacks } from './packs/manifest.js';
import { validateSceneCameras } from './cameraDocument.js';
export { SCENE_DOCUMENT_LIMITS, SceneDocumentError } from './documentFields.js';
-export const SCENE_DOCUMENT_VERSION = 5;
+import { validateSceneInteractions } from './interactions/document.js';
+export const SCENE_DOCUMENT_VERSION = 6;
function visual(value, path, legacy) {
fields(value, path, [
@@ -66,7 +67,7 @@ export function validateSceneDocument(project) {
'scenes',
]);
const version = Object.hasOwn(project, 'version') ? project.version : 1;
- if (![1, 2, 3, 4, 5].includes(version))
+ if (![1, 2, 3, 4, 5, 6].includes(version))
fail('$.version', 'unsupported scene project version');
const legacy = version < 3;
for (const key of ['createdAt', 'updatedAt'])
@@ -126,6 +127,7 @@ export function validateSceneDocument(project) {
'sourcePackVersion',
...(version >= 4 ? ['move'] : []),
...(version >= 5 ? ['dataPackIds'] : []),
+ ...(version >= 6 ? ['interactions'] : []),
]);
uniqueId(shot, at, shotIds);
optional(shot, 'title', at, (v, p) => string(v, p, 4096));
@@ -153,6 +155,7 @@ export function validateSceneDocument(project) {
});
validateSceneCameras(scene, path, version);
validateSceneDataPacks(scene, path);
+ validateSceneInteractions(scene, path);
});
return project;
}
diff --git a/src/director/document.test.mjs b/src/director/document.test.mjs
index 18f4f017a..c0b8b0935 100644
--- a/src/director/document.test.mjs
+++ b/src/director/document.test.mjs
@@ -59,7 +59,7 @@ test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds surviv
const migrated = normalizeProject(
parseSceneDocument(JSON.stringify(project)),
);
- assert.equal(migrated.version, 5);
+ assert.equal(migrated.version, 6);
assert.equal(migrated.scenes[0].shots[0].visual.bloom.intensity, 150);
assert.equal(migrated.scenes[0].shots[0].id, 'shot');
assert.deepEqual(
diff --git a/src/director/interactions/document.js b/src/director/interactions/document.js
new file mode 100644
index 000000000..7d1522608
--- /dev/null
+++ b/src/director/interactions/document.js
@@ -0,0 +1,96 @@
+import {
+ array,
+ fields,
+ string,
+ optional,
+ uniqueId,
+ fail,
+} from '../documentFields.js';
+
+/** Validate inert, scene-local actions. Feature existence is checked after asset loading. */
+export function validateSceneInteractions(scene, path) {
+ const packs = new Map((scene.dataPacks || []).map((p) => [p.id, p]));
+ const anchors = new Set((scene.anchors || []).map((a) => a.id));
+ const shots = new Map(scene.shots.map((s) => [s.id, s]));
+ scene.shots.forEach((shot, index) => {
+ optional(shot, 'interactions', `${path}.shots[${index}]`, (items, at) => {
+ array(items, at, 64);
+ const seen = new Set();
+ items.forEach((item, i) => {
+ const field = `${at}[${i}]`;
+ fields(item, field, ['id', 'label', 'target', 'action']);
+ string(item.id, field);
+ uniqueId(item, field, seen);
+ string(item.label, field, 256);
+ fields(item.target, `${field}.target`, ['packId', 'featureId']);
+ string(item.target.packId, field);
+ string(item.target.featureId, field);
+ if (
+ packs.get(item.target.packId)?.format !== 'geojson' ||
+ !shot.dataPackIds?.includes(item.target.packId)
+ )
+ fail(field, 'target must belong to a selected GeoJSON pack');
+ const a = item.action;
+ const specs = {
+ card: ['text', 'url'],
+ focus: ['anchorId'],
+ shot: ['shotId'],
+ layer: ['layerId', 'enabled'],
+ };
+ if (!a || !Object.hasOwn(specs, a.type))
+ fail(field, 'unsupported action');
+ string(a.type, `${field}.action.type`);
+ fields(a, `${field}.action`, ['type', ...specs[a.type]]);
+ const reference = {
+ focus: 'anchorId',
+ shot: 'shotId',
+ layer: 'layerId',
+ }[a.type];
+ if (reference) string(a[reference], `${field}.action.${reference}`);
+ if (a.type === 'card') {
+ string(a.text, field, 4096);
+ optional(a, 'url', field, (url, at) => {
+ string(url, at, 2048);
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ fail(at, 'expected HTTPS source URL');
+ }
+ if (
+ parsed.protocol !== 'https:' ||
+ parsed.username ||
+ parsed.password ||
+ parsed.search ||
+ parsed.hash
+ )
+ fail(
+ at,
+ 'expected HTTPS source URL without credentials, query or fragment',
+ );
+ });
+ } else if (a.type === 'focus' && !anchors.has(a.anchorId))
+ fail(field, 'unknown anchor');
+ else if (a.type === 'shot') {
+ const target = shots.get(a.shotId);
+ if (!target) fail(field, 'unknown shot');
+ // A transition must restore every layer this shot can change.
+ for (const entry of items)
+ if (
+ entry?.action?.type === 'layer' &&
+ !Object.hasOwn(target.layers || {}, entry.action.layerId)
+ )
+ fail(
+ field,
+ 'target shot must declare interactive layer baseline',
+ );
+ } else if (a.type === 'layer') {
+ if (!Object.hasOwn(shot.layers || {}, a.layerId))
+ fail(field, 'layer must have an explicit shot baseline');
+ if (typeof a.enabled !== 'boolean')
+ fail(field, 'expected enabled boolean');
+ }
+ });
+ });
+ });
+}
diff --git a/src/director/interactions/interactions.test.mjs b/src/director/interactions/interactions.test.mjs
new file mode 100644
index 000000000..fa0aa10ae
--- /dev/null
+++ b/src/director/interactions/interactions.test.mjs
@@ -0,0 +1,247 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { parseSceneDocument, stringifySceneDocument } from '../document.js';
+import { normalizeProject } from '../../scenes/project.js';
+import { createInteractionSession } from './session.js';
+
+const fixture = () => ({
+ version: 6,
+ scenes: [
+ {
+ id: 'scene',
+ anchors: [
+ {
+ id: 'view',
+ lat: 1,
+ lon: 2,
+ alt: 3000,
+ altitudeReference: 'ellipsoid',
+ },
+ ],
+ dataPacks: [
+ {
+ id: 'data',
+ version: 1,
+ format: 'geojson',
+ source: { adapter: 'assets', path: 'test.json' },
+ attribution: { text: 'Example', license: 'CC0' },
+ placement: { altitudeReference: 'ellipsoid' },
+ },
+ ],
+ shots: [
+ {
+ id: 'one',
+ layers: { traffic: false },
+ dataPackIds: ['data'],
+ interactions: [
+ {
+ id: 'card',
+ label: 'Read',
+ target: { packId: 'data', featureId: 'point' },
+ action: {
+ type: 'card',
+ text: '',
+ url: 'https://example.org/source',
+ },
+ },
+ {
+ id: 'focus',
+ label: 'Focus',
+ target: { packId: 'data', featureId: 'point' },
+ action: { type: 'focus', anchorId: 'view' },
+ },
+ {
+ id: 'layer',
+ label: 'Traffic',
+ target: { packId: 'data', featureId: 'point' },
+ action: { type: 'layer', layerId: 'traffic', enabled: true },
+ },
+ {
+ id: 'next',
+ label: 'Next',
+ target: { packId: 'data', featureId: 'point' },
+ action: { type: 'shot', shotId: 'two' },
+ },
+ ],
+ },
+ { id: 'two', layers: { traffic: false } },
+ ],
+ },
+ ],
+});
+
+test('all four inert actions survive validation, migration and export without executing content', () => {
+ const input = fixture();
+ const output = JSON.parse(
+ stringifySceneDocument(
+ normalizeProject(parseSceneDocument(JSON.stringify(input))),
+ ),
+ );
+ assert.deepEqual(
+ output.scenes[0].shots[0].interactions,
+ input.scenes[0].shots[0].interactions,
+ );
+});
+
+test('reject unknown fields, executable syntax, invalid references and missing reset baselines', () => {
+ const mutations = [
+ (p) => (p.version = 5),
+ (p) => {
+ p.scenes[0].shots[0].layers.undefined = false;
+ delete p.scenes[0].shots[0].interactions[2].action.layerId;
+ },
+ (p) => (p.scenes[0].shots[0].interactions[0].action.type = ['card']),
+ (p) => (p.scenes[0].shots[0].interactions[2].action.layerId = ['traffic']),
+ (p) => (p.scenes[0].shots[0].interactions = null),
+ (p) =>
+ p.scenes[0].shots[0].interactions.push(
+ p.scenes[0].shots[0].interactions[0],
+ ),
+ (p) => (p.scenes[0].shots[0].interactions[0].action.code = 'alert(1)'),
+ (p) =>
+ (p.scenes[0].shots[0].interactions[0].action.url = 'javascript:alert(1)'),
+ (p) =>
+ (p.scenes[0].shots[0].interactions[0].action.url =
+ 'https://user:pass@example.org/'),
+ (p) =>
+ (p.scenes[0].shots[0].interactions[0].action.url =
+ 'https://example.org/?token=secret'),
+ (p) => (p.scenes[0].shots[0].interactions[0].target.packId = 'missing'),
+ (p) => (p.scenes[0].shots[0].dataPackIds = []),
+ (p) => (p.scenes[0].shots[0].interactions[1].action.anchorId = 'missing'),
+ (p) => (p.scenes[0].shots[0].interactions[2].action.layerId = 'missing'),
+ (p) => (p.scenes[0].shots[0].interactions[2].action.enabled = 'yes'),
+ (p) => (p.scenes[0].shots[0].interactions[3].action.shotId = 'missing'),
+ (p) => (p.scenes[0].shots[1].layers = {}),
+ (p) =>
+ (p.scenes[0].shots[0].interactions = Array.from(
+ { length: 65 },
+ (_, i) => ({ ...p.scenes[0].shots[0].interactions[0], id: String(i) }),
+ )),
+ ];
+ for (const mutate of mutations) {
+ const p = fixture();
+ mutate(p);
+ assert.throws(() => parseSceneDocument(JSON.stringify(p)));
+ }
+});
+
+test('pending actions cancel promptly, refuse overlap and cannot update a replacement session', async () => {
+ let resolve,
+ signal,
+ calls = 0;
+ const session = createInteractionSession({
+ execute: (_, s) => {
+ calls++;
+ signal = s;
+ return new Promise((r) => (resolve = r));
+ },
+ });
+ session.activate([{ id: 'old' }]);
+ const work = session.dispatch('old');
+ await Promise.resolve();
+ assert.equal(await session.dispatch('old'), false);
+ assert.equal(await session.dispatch('missing'), false);
+ session.activate([{ id: 'new' }]);
+ assert.equal(signal.aborted, true);
+ assert.equal(await work, false);
+ resolve(true);
+ await Promise.resolve();
+ assert.deepEqual(session.getState(), {
+ active: true,
+ busy: false,
+ selected: null,
+ count: 1,
+ });
+ assert.equal(calls, 1);
+ session.clear();
+ assert.equal(await session.dispatch('new'), false);
+});
+
+test('synchronous stop before execution prevents any side effect; rejection unlocks retry', async () => {
+ let calls = 0;
+ const session = createInteractionSession({
+ execute: () => {
+ calls++;
+ throw new Error('refused');
+ },
+ });
+ session.activate([{ id: 'a' }]);
+ const work = session.dispatch('a');
+ session.clear();
+ assert.equal(await work, false);
+ assert.equal(calls, 0);
+ session.activate([{ id: 'a' }]);
+ assert.equal(await session.dispatch('a'), false);
+ assert.equal(session.getState().busy, false);
+ assert.equal(await session.dispatch('a'), false);
+ assert.equal(calls, 2);
+});
+
+test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => {
+ const { SceneDirector } = await import('../../scenes/director.js');
+ const shot = { id: 'shot', dataPackIds: ['pack'] };
+ const director = Object.assign(Object.create(SceneDirector.prototype), {
+ _destroyed: false,
+ _loadedSceneId: 'scene',
+ _selectedShotId: 'shot',
+ _claimCameraOwnership: () =>
+ assert.fail('Must reload the released pack before camera seek'),
+ });
+ assert.equal(director._seekLoadedShot({ id: 'scene' }, { shot }), false);
+});
+
+test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => {
+ const { SceneDirector } = await import('../../scenes/director.js');
+ const owner = new AbortController();
+ const director = Object.assign(Object.create(SceneDirector.prototype), {
+ _running: false,
+ _destroyed: false,
+ _interactionTransitions: 64,
+ _getShot: () => ({ scene: { id: 'scene' }, shot: { id: 'shot' } }),
+ _claimCameraOwnership: () => false,
+ _setCameraView: () => assert.fail('Refused camera cannot move'),
+ _updateStatus: () => {},
+ _loadShot: () => assert.fail('Transition budget exceeded'),
+ dataManager: {
+ getAll: () => [{ id: 'traffic' }],
+ setEnabled: (id, enabled, options) => {
+ assert.equal(id, 'traffic');
+ assert.equal(enabled, true);
+ assert.equal(options.signal, owner.signal);
+ assert.equal(options.origin, 'scene');
+ return false;
+ },
+ },
+ });
+ assert.equal(
+ await director._executeInteraction({ type: 'focus' }, owner.signal),
+ false,
+ );
+ assert.equal(
+ await director._executeInteraction(
+ { type: 'shot', shotId: 'next' },
+ owner.signal,
+ ),
+ false,
+ );
+ assert.equal(
+ await director._executeInteraction(
+ { type: 'layer', layerId: 'traffic', enabled: true },
+ owner.signal,
+ ),
+ false,
+ );
+ assert.equal(
+ await director._executeInteraction(
+ { type: 'layer', layerId: 'unknown', enabled: true },
+ owner.signal,
+ ),
+ false,
+ );
+ owner.abort();
+ assert.equal(
+ await director._executeInteraction({ type: 'focus' }, owner.signal),
+ false,
+ );
+});
diff --git a/src/director/interactions/session.js b/src/director/interactions/session.js
new file mode 100644
index 000000000..2f305b675
--- /dev/null
+++ b/src/director/interactions/session.js
@@ -0,0 +1,61 @@
+/** Own one user-triggered action at a time; cancellation settles even an uncooperative adapter. */
+export function createInteractionSession({ execute, changed = () => {} }) {
+ let controller,
+ active = false,
+ busy = false,
+ selected = null;
+ let actions = new Map();
+ const state = () => ({ active, busy, selected, count: actions.size });
+ function clear() {
+ active = false;
+ busy = false;
+ selected = null;
+ actions.clear();
+ controller?.abort();
+ controller = null;
+ changed(state());
+ }
+ return {
+ clear,
+ getState: state,
+ activate(items) {
+ clear();
+ actions = new Map(items.map((item) => [item.id, item]));
+ active = !!actions.size;
+ changed(state());
+ },
+ async dispatch(id) {
+ const item = actions.get(id);
+ if (!active || busy || !item) return false;
+ const current = new AbortController();
+ controller = current;
+ busy = true;
+ selected = id;
+ changed(state());
+ let abort;
+ try {
+ const cancelled = new Promise((resolve) => {
+ abort = () => resolve(false);
+ current.signal.addEventListener('abort', abort, { once: true });
+ });
+ const work = Promise.resolve().then(() => {
+ if (current.signal.aborted) return false;
+ return execute(item, current.signal);
+ });
+ return (
+ (await Promise.race([work, cancelled])) !== false &&
+ !current.signal.aborted
+ );
+ } catch {
+ return false;
+ } finally {
+ current.signal.removeEventListener('abort', abort);
+ if (controller === current) {
+ busy = false;
+ controller = null;
+ changed(state());
+ }
+ }
+ },
+ };
+}
diff --git a/src/director/packs/packs.test.mjs b/src/director/packs/packs.test.mjs
index f31d65090..ed5d42500 100644
--- a/src/director/packs/packs.test.mjs
+++ b/src/director/packs/packs.test.mjs
@@ -51,7 +51,7 @@ test('v5 packs and shot references round trip through project migration without
JSON.stringify({ version, scenes: [{ id: 'old', shots: [] }] }),
),
);
- assert.equal(old.version, 5);
+ assert.equal(old.version, 6);
assert.equal(old.scenes[0].dataPacks, undefined);
}
});
diff --git a/src/director/playback.js b/src/director/playback.js
index 5cdeb0297..4542d16f1 100644
--- a/src/director/playback.js
+++ b/src/director/playback.js
@@ -122,3 +122,5 @@ export {
export { createAssetDirectorySource } from './packs/source.js';
export { createDataPackSession } from './packs/session.js';
export { validateDataPack, PACK_LIMITS } from './packs/manifest.js';
+
+export { createInteractionSession } from './interactions/session.js';
diff --git a/src/scenes/dataPacks/controller.js b/src/scenes/dataPacks/controller.js
index c031ab6d8..6d70a31fd 100644
--- a/src/scenes/dataPacks/controller.js
+++ b/src/scenes/dataPacks/controller.js
@@ -3,12 +3,14 @@ import { createPackPresentations } from './presentation.js';
/** Bind selected shot packs to one viewer and expose copied lifecycle diagnostics. */
export function createSceneDataPacks(viewer, { sources = {} } = {}) {
+ const targets = new Map();
const session = createDataPackSession({
sources,
- adapters: createPackPresentations(viewer),
+ adapters: createPackPresentations(viewer, targets),
});
return {
...session,
+ getTargets: () => new Map(targets),
apply(scene, shot, token) {
const wanted = new Set(shot.dataPackIds || []);
return session.load(
diff --git a/src/scenes/dataPacks/geometry.js b/src/scenes/dataPacks/geometry.js
index 5023386fb..f81b1e58f 100644
--- a/src/scenes/dataPacks/geometry.js
+++ b/src/scenes/dataPacks/geometry.js
@@ -11,8 +11,8 @@ export function renderPackGeometry({ asset, pack }, handle) {
let lines;
for (const feature of features) {
const id = { packId: pack.id, featureId: feature.id };
- if (feature.type === 'Point')
- handle.add({
+ if (feature.type === 'Point') {
+ const entity = handle.add({
name: `${pack.id} / ${feature.id}`,
position: xyz(feature.coordinates),
point: {
@@ -22,7 +22,9 @@ export function renderPackGeometry({ asset, pack }, handle) {
outlineWidth: 2,
},
});
- else if (feature.type === 'LineString') {
+ handle.feature?.(id, entity);
+ } else if (feature.type === 'LineString') {
+ handle.feature?.(id, id);
if (!lines) {
lines = new Cesium.PolylineCollection();
handle.primitive(lines);
@@ -35,7 +37,8 @@ export function renderPackGeometry({ asset, pack }, handle) {
color: Cesium.Color.CYAN,
}),
});
- } else
+ } else {
+ handle.feature?.(id, id);
polygons.push(
new Cesium.GeometryInstance({
id,
@@ -57,6 +60,7 @@ export function renderPackGeometry({ asset, pack }, handle) {
},
}),
);
+ }
}
if (polygons.length)
handle.primitive(
diff --git a/src/scenes/dataPacks/presentation.js b/src/scenes/dataPacks/presentation.js
index 77c05b76d..efdf5cbac 100644
--- a/src/scenes/dataPacks/presentation.js
+++ b/src/scenes/dataPacks/presentation.js
@@ -30,7 +30,7 @@ function caption(viewer, pack) {
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
/** Create rendering adapters; data acquisition and authored placement stay independent. */
-export function createPackPresentations(viewer) {
+export function createPackPresentations(viewer, targets = new Map()) {
let panel;
function addCard(pack) {
if (!panel) {
@@ -55,9 +55,15 @@ export function createPackPresentations(viewer) {
primitives = [],
materials = [],
urls = [];
+ const featureKeys = [];
let card,
destroyed = false;
return {
+ feature(id, pickedId) {
+ const key = JSON.stringify([id.packId, id.featureId]);
+ targets.set(key, pickedId);
+ featureKeys.push(key);
+ },
add(spec) {
const entity = viewer.entities.add(spec);
entities.push(entity);
@@ -81,6 +87,7 @@ export function createPackPresentations(viewer) {
dispose() {
if (destroyed) return;
destroyed = true;
+ for (const key of featureKeys.splice(0)) targets.delete(key);
for (const media of card?.querySelectorAll('video,audio') || []) {
media.pause();
media.removeAttribute('src');
diff --git a/src/scenes/director.js b/src/scenes/director.js
index 7c53be152..087a50dc9 100644
--- a/src/scenes/director.js
+++ b/src/scenes/director.js
@@ -26,6 +26,7 @@ import {
} from './recipes.js';
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
import { createSceneDataPacks } from './dataPacks/controller.js';
+import { createSceneInteractions } from './interactions.js';
import { createDefaultScenePacks } from './packs/defaults.js';
import {
layerStatesForShot,
@@ -89,10 +90,23 @@ export class SceneDirector {
this._isMapStackAvailable = isMapStackAvailable;
this._scenePacks = scenePacks;
this._dataPacks = createSceneDataPacks(viewer, dataPacks);
+ this._interactionTransitions = 0;
+ this._interactions = createSceneInteractions(viewer, {
+ available: () =>
+ !this._destroyed && !this._running && !this.viewer.trackedEntity,
+ execute: (action, signal) =>
+ this._trackWork(this._executeInteraction(action, signal)),
+ });
this._cameraMotion = createCameraMotion({
applyPose: (pose) => this._setCameraView(pose),
});
- const yieldCamera = () => {
+ const yieldCamera = (event) => {
+ // A settled feature click belongs to the action picker, not a new camera gesture.
+ if (
+ event?.type === 'pointerdown' &&
+ this._interactions?.getState().active
+ )
+ return;
if (this._usesAuthoredCamera && !this._claimingCamera)
this.stopScene('Camera ownership changed');
};
@@ -203,6 +217,7 @@ export class SceneDirector {
this._cameraHandoffUnsubscribe?.();
this._removeCameraInput?.();
this._cameraMotion?.destroy();
+ this._interactions?.destroy();
this._dataPacks?.destroy();
this._controls?.destroy();
this._state.destroy();
@@ -976,6 +991,7 @@ export class SceneDirector {
if (this._destroyed)
return Promise.resolve({ started: false, reason: 'destroyed' });
this._sceneSeekGeneration++;
+ this._interactionTransitions = 0;
return this._trackWork(this._loadShot(sceneId, shotId, options));
}
@@ -1001,6 +1017,7 @@ export class SceneDirector {
// manager) rather than merely ignored once it has already committed.
this._cancelActiveSceneTravel();
this._usesAuthoredCamera = !!shot.move;
+ this._interactions?.clear();
this._dataPacks?.clear();
this._loadAbort?.abort();
const controller = new AbortController();
@@ -1060,6 +1077,7 @@ export class SceneDirector {
});
if (this._loadAbort === controller) this._loadAbort = null;
this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`);
+ this._activateInteractions(scene, shot);
return { started: true, shotId };
}
const holdSec = this._effectiveShotHoldSec(scene, shot);
@@ -1109,6 +1127,7 @@ export class SceneDirector {
);
if (this._loadAbort === controller) this._loadAbort = null;
+ this._activateInteractions(scene, shot);
this._shotOutcome('shot-loaded', scene, shot);
this._updateRuntime('');
return { started: true, shotId };
@@ -1359,6 +1378,8 @@ export class SceneDirector {
if (
!scene ||
!shot ||
+ shot.dataPackIds?.length ||
+ shot.interactions?.length ||
this._loadedSceneId !== scene.id ||
this._selectedShotId !== shot.id
)
@@ -1621,6 +1642,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._interactions?.clear();
this._dataPacks?.clear();
this._loadAbort?.abort();
this._loadAbort = null;
@@ -1732,6 +1754,7 @@ export class SceneDirector {
* @param {string} [reason='Stopped'] - Human-readable cancellation reason
*/
stopScene(reason = 'Stopped') {
+ this._interactions?.clear();
this._dataPacks?.clear();
this._sceneSeekGeneration++;
this._loadAbort?.abort();
@@ -1763,6 +1786,71 @@ export class SceneDirector {
return this._dataPacks?.getState() || { status: 'idle', count: 0 };
}
+ /** Activate only after LOAD/seek settles. Running timelines never branch automatically. */
+ _activateInteractions(scene, shot) {
+ try {
+ this._interactions?.activate(shot, this._dataPacks.getTargets());
+ } catch (error) {
+ this._updateStatus(error.message);
+ }
+ }
+
+ /** Execute a validated action through the existing camera and layer admission paths. */
+ async _executeInteraction(action, signal) {
+ if (signal.aborted || this._running || this._destroyed) return false;
+ const { scene, shot } = this._getShot(
+ this._selectedSceneId,
+ this._selectedShotId,
+ );
+ if (!scene || !shot) return false;
+ if (action.type === 'focus') {
+ if (!this._claimCameraOwnership()) return false;
+ this._cancelActiveSceneTravel();
+ this._clock.stopShot();
+ return this._setCameraView(
+ resolveCameraPose(scene, { anchorId: action.anchorId, pitch: -90 }),
+ );
+ }
+ if (action.type === 'layer') {
+ if (
+ !this.dataManager.getAll().some((layer) => layer.id === action.layerId)
+ )
+ return false;
+ return this.dataManager.setEnabled(action.layerId, action.enabled, {
+ signal,
+ origin: 'scene',
+ });
+ }
+ if (action.type === 'shot') {
+ if (this._interactionTransitions >= 64) {
+ this._updateStatus(
+ 'Scene transition limit reached — load a shot to reset',
+ );
+ return false;
+ }
+ this._interactionTransitions++;
+ // Replacement clears this action's owner; the new LOAD owns its own cancellation.
+ const target = scene.shots.find((item) => item.id === action.shotId);
+ return this.seekScene(
+ scene.id,
+ this._sceneTimingForShot(scene, target).startProgress,
+ );
+ }
+ return false;
+ }
+
+ /** Copied interaction state for lifecycle diagnostics. */
+ getInteractionState() {
+ return (
+ this._interactions?.getState() || {
+ active: false,
+ busy: false,
+ selected: null,
+ count: 0,
+ }
+ );
+ }
+
/** Export the entire project as a timestamped JSON file download. */
exportProject() {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -1937,6 +2025,7 @@ export class SceneDirector {
* @returns {Promise} True only when every owned layer is released
*/
async _releaseSceneLayers(scene, token = null) {
+ this._interactions?.clear();
this._dataPacks?.clear();
const layerIds = Array.isArray(scene?.releaseLayerIds)
? scene.releaseLayerIds
diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs
index 4bdccaac9..de00c8cc4 100644
--- a/src/scenes/director.test.mjs
+++ b/src/scenes/director.test.mjs
@@ -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, 5);
+ assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 6);
} finally { restore(); }
});
diff --git a/src/scenes/interactions.js b/src/scenes/interactions.js
new file mode 100644
index 000000000..8bb660715
--- /dev/null
+++ b/src/scenes/interactions.js
@@ -0,0 +1,165 @@
+import * as Cesium from 'cesium';
+import { isPointerFree } from '../data/inputOwnership.js';
+import { createInteractionSession } from '../director/interactions/session.js';
+
+/** Own the active shot's pick handler, accessible actions and plain-text feedback. */
+export function createSceneInteractions(
+ viewer,
+ { execute, available = () => true },
+) {
+ let panel,
+ status,
+ card,
+ handler,
+ items = [],
+ targets = new Map();
+ const buttons = new Map();
+ const session = createInteractionSession({
+ execute: (item, signal) => {
+ if (!isPointerFree() || !available()) return false;
+ card.replaceChildren();
+ if (item.action.type === 'card') {
+ const text = document.createElement('p');
+ text.textContent = item.action.text;
+ card.append(text);
+ if (item.action.url) {
+ const link = document.createElement('a');
+ link.textContent = 'Source';
+ link.style.color = '#6eeaff';
+ link.href = item.action.url;
+ link.target = '_blank';
+ link.rel = 'noopener noreferrer';
+ card.append(link);
+ }
+ return true;
+ }
+ return execute(item.action, signal);
+ },
+ changed(state) {
+ for (const [id, button] of buttons) {
+ button.disabled = state.busy;
+ button.setAttribute('aria-pressed', String(state.selected === id));
+ button.style.outline = state.selected === id ? '2px solid #6eeaff' : '';
+ }
+ },
+ });
+ function clear() {
+ session.clear();
+ handler?.destroy();
+ handler = null;
+ panel?.remove();
+ panel = null;
+ status = null;
+ card = null;
+ buttons.clear();
+ items = [];
+ targets.clear();
+ }
+ async function dispatch(id) {
+ const current = panel;
+ const ok = await session.dispatch(id);
+ if (current && current === panel && status)
+ status.textContent = ok
+ ? 'Action complete'
+ : 'Action unavailable or cancelled';
+ return ok;
+ }
+ return {
+ clear,
+ destroy: clear,
+ getState: session.getState,
+ activate(shot, registered) {
+ clear();
+ items = shot.interactions || [];
+ if (!items.length) return;
+ targets = registered;
+ if (
+ items.some(
+ (item) =>
+ !targets.has(
+ JSON.stringify([item.target.packId, item.target.featureId]),
+ ),
+ )
+ ) {
+ items = [];
+ targets.clear();
+ throw new Error('Interaction feature is missing from the loaded pack');
+ }
+ panel = document.createElement('section');
+ panel.dataset.directorInteractions = '';
+ panel.setAttribute('aria-label', 'Scene actions');
+ Object.assign(panel.style, {
+ position: 'absolute',
+ left: '12px',
+ bottom: '80px',
+ zIndex: '31',
+ maxWidth: '300px',
+ maxHeight: '40vh',
+ overflowY: 'auto',
+ background: '#08141eed',
+ color: '#e3faff',
+ border: '1px solid #2491a8',
+ padding: '10px',
+ font: '13px sans-serif',
+ });
+ const owner = panel;
+ const title = document.createElement('strong');
+ title.textContent = 'Scene actions';
+ panel.append(title);
+ status = document.createElement('p');
+ status.setAttribute('role', 'status');
+ status.textContent =
+ 'Select a feature or use Tab and Enter to choose an action.';
+ panel.append(status);
+ for (const item of items) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.textContent = item.label;
+ button.dataset.directorAction = item.id;
+ Object.assign(button.style, {
+ display: 'block',
+ background: '#10242e',
+ color: '#e3faff',
+ border: '1px solid #397080',
+ borderRadius: '3px',
+ padding: '6px 10px',
+ cursor: 'pointer',
+ font: 'inherit',
+ margin: '6px 0',
+ maxWidth: '100%',
+ whiteSpace: 'normal',
+ });
+ button.addEventListener('click', () => {
+ if (panel === owner) void dispatch(item.id);
+ });
+ buttons.set(item.id, button);
+ panel.append(button);
+ }
+ card = document.createElement('div');
+ card.dataset.directorActionCard = '';
+ panel.append(card);
+ viewer.container.append(panel);
+ session.activate(items);
+ handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
+ handler.setInputAction((event) => {
+ if (panel !== owner || !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;
+ for (const item of items) {
+ const button = buttons.get(item.id);
+ button.style.outline = matches.includes(item)
+ ? '2px solid #6eeaff'
+ : '';
+ }
+ status.textContent = `Selected feature: ${matches[0].target.featureId}. Choose an action.`;
+ buttons.get(matches[0].id)?.focus({ preventScroll: true });
+ }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
+ },
+ };
+}
diff --git a/src/scenes/project.js b/src/scenes/project.js
index eb1d0d49f..ecbc14b56 100644
--- a/src/scenes/project.js
+++ b/src/scenes/project.js
@@ -253,6 +253,9 @@ export function normalizeShot(
? { altitudeReference: camera.altitudeReference || 'ellipsoid' }
: {}),
},
+ ...(rawShot?.interactions
+ ? { interactions: deepClone(rawShot.interactions) }
+ : {}),
...(rawShot?.dataPackIds ? { dataPackIds: [...rawShot.dataPackIds] } : {}),
...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}),
visual: {