Draw the last native world labels through the label overlay

The Directions A and B letters and a data pack's media label were the two
Cesium text labels left, kept by an allow-list in noCesiumLabels.test.mjs.
Both now publish entries to the shared world-overlay host, and the
allow-list is gone, so any Cesium label in src is a test failure.

Directions publishes the letters to the directions-markers source; the
markers keep only their points. A clicked endpoint's letter hangs over the
surface that was clicked. One placed without a click stays hidden until its
ground-floor cell resolves, then requests a render, so a still camera shows
it; a cell that settles with no height falls back to the ellipsoid. Each
position is computed once.

A pack resource handle gains label(entry). The application passes its
shared overlay host to SceneDirector through dataPacks.overlayHost; without
one a media pack fails to load rather than drawing a native label. Each
labelled resource takes a source from a pool and returns it on dispose, as
the host never removes a source, so two presentations of one pack never
clear each other's labels and repeat loads add no sources. A label written
after dispose is ignored. The media label is protected, so it always draws
as before.

Live (Denver, keyless globe): a clicked A and a programmatic B both anchor
on their dots over 1.58 km ground. Documented in docs/CURRENT-STATE.md and
docs/DIRECTOR-DATA-PACKS.md.
This commit is contained in:
Sameh Khamis committed 2026-11-04 12:30:31 -08:00
1 parent ad8adc85f4
commit fb30287898
12 files changed
+382 -44

No files matched your search

+8 -2
View File
@@ -3463,8 +3463,14 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> celestial ring, annotations SVG, missions frame tick, and traffic). > celestial ring, annotations SVG, missions frame tick, and traffic).
> - **World-label accounting:** All intended label/card migrations use the > - **World-label accounting:** All intended label/card migrations use the
> shared host. The former native `LabelGraphics` exception for cable reference > shared host. The former native `LabelGraphics` exception for cable reference
> labels was retired on 2026-08-18; cable text may render over tile geometry, > labels was retired on 2026-08-18; cable text may render over tile geometry.
> so **zero native world-label creation sites remain**. The mission replay DOM vehicle, awareness > The Directions A/B letters (`directions-markers` source) and data-pack media
> labels (a pooled `data-pack-labels:<n>` source per pack resource, through
> the pack handle's `label`) moved to the host on 2026-11-04, and
> `src/noCesiumLabels.test.mjs` no longer keeps an allow-list, so **zero
> native world-label creation sites remain**. A media pack shown without an
> `overlayHost` fails to load rather than drawing a native label. The mission
> replay DOM vehicle, awareness
> compass, annotation SVG callouts, and host-owned detection isolation target > compass, annotation SVG callouts, and host-owned detection isolation target
> are explicit exceptions; the awareness ring and cockpit contact pips are > are explicit exceptions; the awareness ring and cockpit contact pips are
> non-text out-of-scope surfaces. Annotation callout text, geometry, leaders, > non-text out-of-scope surfaces. Annotation callout text, geometry, leaders,
+5
View File
@@ -95,6 +95,11 @@ registry of source functions. Each receives `{ path, signal, maxBytes }` and
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
signal, bound reads and return the real MIME type. They remain application code, signal, bound reads and return the real MIME type. They remain application code,
not scene document fields. Without registrations, referenced sources fail closed. not scene document fields. Without registrations, referenced sources fail closed.
A standalone `SceneDirector` also passes `dataPacks.overlayHost`, the
world-overlay host that draws pack world text such as a media pack's label (an
object with `setEntries(sourceId, entries)` and `clearSource(sourceId)`);
without it a media pack fails to load. `createApplicationTools` passes the
application's shared host.
The portable session receives format adapters separately. Each adapter receives The portable session receives format adapters separately. Each adapter receives
`{ pack, asset, anchors, signal }` and returns a resource with an idempotent `{ pack, asset, anchors, signal }` and returns a resource with an idempotent
+2 -1
View File
@@ -18,6 +18,7 @@ import {
releaseContinuousRender, releaseContinuousRender,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { entityRecord, readEntityRecord } from '../data/entityRecords.js'; import { entityRecord, readEntityRecord } from '../data/entityRecords.js';
import { overlayHost } from './layers/overlayHost.js';
/** Attach scene tools, rendering listeners and the application debug handle. */ /** Attach scene tools, rendering listeners and the application debug handle. */
export function createApplicationTools({ export function createApplicationTools({
@@ -37,7 +38,7 @@ export function createApplicationTools({
const { styleManager, weatherEffects, cockpitCloudEffects } = controls; const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data; const { dataManager } = data;
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, { const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
dataPacks: sceneDataPacks, dataPacks: { overlayHost, ...sceneDataPacks },
isMapStackAvailable: (id) => isMapStackAvailable: (id) =>
mapStackController?.isStackAvailable(id) === true, mapStackController?.isStackAvailable(id) === true,
}); });
+2
View File
@@ -54,6 +54,8 @@ export {
STEP_ANCHOR_FAST_ATTEMPTS, STEP_ANCHOR_FAST_ATTEMPTS,
STEP_ANCHOR_RETRY_MS, STEP_ANCHOR_RETRY_MS,
STEP_ANCHOR_SLOW_RETRY_MS, STEP_ANCHOR_SLOW_RETRY_MS,
DIRECTIONS_MARKER_OVERLAY_SOURCE_ID,
createDirectionsMarkerOverlayEntry,
createDirectionsStepOverlayEntry, createDirectionsStepOverlayEntry,
directionsRequestUrl, directionsRequestUrl,
directionsRowControls, directionsRowControls,
+82
View File
@@ -6,6 +6,8 @@ import {
DIRECTIONS_MODES, DIRECTIONS_MODES,
DIRECTIONS_POINTER_OWNER, DIRECTIONS_POINTER_OWNER,
DIRECTIONS_ROUTE_COLOR, DIRECTIONS_ROUTE_COLOR,
DIRECTIONS_MARKER_OVERLAY_SOURCE_ID,
createDirectionsMarkerOverlayEntry,
createDirectionsStepOverlayEntry, createDirectionsStepOverlayEntry,
directionsRequestUrl, directionsRequestUrl,
directionsRowControls, directionsRowControls,
@@ -950,3 +952,83 @@ test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen', 'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
); );
}); });
test('the A and B letters are protected overlay labels in the marker colour', () => {
const position = Cesium.Cartesian3.fromDegrees(-122.39, 37.79);
const entry = createDirectionsMarkerOverlayEntry('B', position, '#ff6b6b');
assert.equal(entry.id, 'directions-marker-b');
assert.equal(entry.title, 'B');
assert.equal(entry.variant, 'label');
assert.equal(entry.accent, '#ff6b6b');
assert.equal(entry.protected, true);
assert.equal(entry.position, position);
assert.equal(entry.placement, 'above');
});
test('a placed endpoint draws its letter through the overlay, not as entity text', async (t) => {
const published = [];
const cleared = [];
const visibility = [];
const entities = [];
const markerVisibility = () =>
visibility
.filter(([source]) => source === DIRECTIONS_MARKER_OVERLAY_SOURCE_ID)
.at(-1)?.[1];
directionsLayer = createApplicationDirectionsLayer();
directionsLayer._setOverlayHostForTest({
setEntries: (source, entries) => published.push([source, entries]),
setVisible: (source, visible) => visibility.push([source, visible]),
clearSource: (source) => cleared.push(source),
});
resetPointerOwnership();
const viewer = {
isDestroyed: () => false,
entities: {
add: (spec) => (entities.push(spec), spec),
remove() {},
},
scene: {
requestRender() {},
primitives: {
add: (primitive) => primitive,
remove() {},
raiseToTop() {},
lowerToBottom() {},
contains: () => true,
length: 0,
},
},
};
t.after(() => {
directionsLayer.destroy(viewer);
directionsLayer._setOverlayHostForTest(null);
resetPointerOwnership();
});
directionsLayer.init(viewer);
assert.equal(markerVisibility(), false, 'hidden until enabled');
directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 });
assert.equal(entities.length, 1);
assert.equal(entities[0].label, undefined, 'no Cesium label on the marker');
assert.ok(entities[0].point, 'the marker keeps its point');
const [source, entries] = published.at(-1);
assert.equal(source, DIRECTIONS_MARKER_OVERLAY_SOURCE_ID);
assert.deepEqual(
entries.map((entry) => entry.title),
['A'],
);
// Hidden until the floor cell settles, then anchored (the ellipsoid here,
// where the test resolver finds no height).
assert.equal(entries[0].position(), null);
const started = Date.now();
while (!entries[0].position() && Date.now() - started < 5000)
await new Promise((resolve) => setTimeout(resolve, 10));
const anchor = Cesium.Cartographic.fromCartesian(entries[0].position());
assert.ok(Math.abs(Cesium.Math.toDegrees(anchor.latitude) - 37.7955) < 1e-9);
directionsLayer.setParams({ clear: true });
assert.ok(cleared.includes(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID));
cleared.length = 0;
directionsLayer.destroy(viewer);
assert.ok(cleared.includes(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID));
});
+1 -1
View File
@@ -71,7 +71,7 @@ test('Directions introduces the shared OSM credit when the requested route draws
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop }, render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },
sprites: { registerSpriteCollection: noop, unregisterSpriteCollection: noop, restoreSpriteOrder: noop }, sprites: { registerSpriteCollection: noop, unregisterSpriteCollection: noop, restoreSpriteOrder: noop },
picking: { registerPickOwner: noop, unregisterPickOwner: noop }, picking: { registerPickOwner: noop, unregisterPickOwner: noop },
ground: { warmGroundFloor: noop }, ground: { warmGroundFloor: noop, resolveGroundFloorCells: async () => {} },
} }); } });
layer.init(v); layer.enable(v); layer.init(v); layer.enable(v);
layer.placeEndpoint('a', { lat: 0, lon: 0 }); layer.placeEndpoint('b', { lat: 0.01, lon: 0.01 }); layer.placeEndpoint('a', { lat: 0, lon: 0 }); layer.placeEndpoint('b', { lat: 0.01, lon: 0.01 });
+108 -14
View File
@@ -40,6 +40,13 @@ export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
collisionCapacity: 0, collisionCapacity: 0,
moving: false, moving: false,
}); });
/** The A and B letters above the endpoint markers. */
export const DIRECTIONS_MARKER_OVERLAY_SOURCE_ID = 'directions-markers';
export const DIRECTIONS_MARKER_OVERLAY_SOURCE_OPTIONS = Object.freeze({
cohortLimit: 2,
collisionCapacity: 0,
moving: false,
});
/** Travel modes, keyed by the `/api/route` profile name. */ /** Travel modes, keyed by the `/api/route` profile name. */
export const DIRECTIONS_MODES = Object.freeze({ export const DIRECTIONS_MODES = Object.freeze({
@@ -69,8 +76,10 @@ export function presentRoutingFailure(error) {
/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */ /** Route colour — the annotation palette's cyan, so voice routes and Directions match. */
export const DIRECTIONS_ROUTE_COLOR = '#39d0ff'; export const DIRECTIONS_ROUTE_COLOR = '#39d0ff';
const MARKER_A_COLOR = Cesium.Color.fromCssColorString('#5dff9f'); const MARKER_A_CSS = '#5dff9f';
const MARKER_B_COLOR = Cesium.Color.fromCssColorString('#ff6b6b'); const MARKER_B_CSS = '#ff6b6b';
const MARKER_A_COLOR = Cesium.Color.fromCssColorString(MARKER_A_CSS);
const MARKER_B_COLOR = Cesium.Color.fromCssColorString(MARKER_B_CSS);
const STEP_COLOR = Cesium.Color.WHITE.withAlpha(0.95); const STEP_COLOR = Cesium.Color.WHITE.withAlpha(0.95);
const STEP_OUTLINE = Cesium.Color.fromCssColorString(DIRECTIONS_ROUTE_COLOR); const STEP_OUTLINE = Cesium.Color.fromCssColorString(DIRECTIONS_ROUTE_COLOR);
const STEP_PIXEL_SIZE = 8; const STEP_PIXEL_SIZE = 8;
@@ -796,6 +805,32 @@ export function createDirectionsStepOverlayEntry(index, position, copy) {
}; };
} }
/**
* The letter above one endpoint marker, as a world-overlay label. It is
* protected, like the step card, so it never yields to ambient labels.
* @param {'A'|'B'} letter
* @param {Cesium.Cartesian3|(() => Cesium.Cartesian3)} position
* @param {string} accent CSS colour of the marker.
*/
export function createDirectionsMarkerOverlayEntry(letter, position, accent) {
return {
id: `directions-marker-${letter.toLowerCase()}`,
position,
variant: 'label',
title: letter,
accent,
protected: true,
priority: Number.MAX_SAFE_INTEGER - 1,
interactive: false,
gapPx: 13,
verticalOnly: true,
placement: 'above',
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
};
}
/** /**
* Build the `/api/route` URL for two endpoints. * Build the `/api/route` URL for two endpoints.
* @param {string} mode car | foot | bike * @param {string} mode car | foot | bike
@@ -881,6 +916,10 @@ export function createDirectionsLayer({ services, source }) {
let _mode = DEFAULT_DIRECTIONS_MODE; let _mode = DEFAULT_DIRECTIONS_MODE;
/** @type {null|'a'|'b'} which endpoint the next globe click places */ /** @type {null|'a'|'b'} which endpoint the next globe click places */
let _armed = null; let _armed = null;
/** Surface heights of clicked endpoints, by point. */
const _surfaceHeights = new WeakMap();
/** Endpoints whose floor cell resolve has settled, height or not. */
const _floorSettled = new WeakSet();
/** @type {{lat:number, lon:number}|null} */ /** @type {{lat:number, lon:number}|null} */
let _a = null; let _a = null;
/** @type {{lat:number, lon:number}|null} */ /** @type {{lat:number, lon:number}|null} */
@@ -975,10 +1014,14 @@ export function createDirectionsLayer({ services, source }) {
if (!cartesian) return null; if (!cartesian) return null;
const carto = Cesium.Cartographic.fromCartesian(cartesian); const carto = Cesium.Cartographic.fromCartesian(cartesian);
if (!carto) return null; if (!carto) return null;
return { const point = {
lat: Cesium.Math.toDegrees(carto.latitude), lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude), lon: Cesium.Math.toDegrees(carto.longitude),
}; };
// A pick at the ellipsoid itself is the fallback when the drawn surface
// gave no position; the floor path places that letter instead.
if (Math.abs(carto.height) > 0.01) _surfaceHeights.set(point, carto.height);
return point;
} }
function markerEntity(letter, point, color) { function markerEntity(letter, point, color) {
@@ -993,20 +1036,55 @@ export function createDirectionsLayer({ services, source }) {
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY, disableDepthTestDistance: Number.POSITIVE_INFINITY,
}, },
label: {
text: letter,
font: 'bold 13px "JetBrains Mono", "SF Mono", monospace',
fillColor: Cesium.Color.WHITE,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -20),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
}); });
} }
/**
* Where a marker's letter hangs: over the surface the user clicked, or for an
* endpoint placed without a click, over the shared ground floor once its cell
* is known. Until then the letter stays hidden, as the step dots do, rather
* than hanging at the ellipsoid far below its point; only a cell that settled
* with no height falls back to the ellipsoid. Resolved once.
*/
function markerLabelPosition(point) {
let position = null;
return () => {
if (position) return position;
const height =
_surfaceHeights.get(point) ??
services.ground.cachedGroundFloor(point.lat, point.lon);
if (!Number.isFinite(height) && !_floorSettled.has(point)) return null;
position = Cesium.Cartesian3.fromDegrees(
point.lon,
point.lat,
Number.isFinite(height) ? height : 0,
);
return position;
};
}
/** Publish the letters for whichever endpoints are placed. */
function publishMarkerLabels() {
const entries = [];
for (const [letter, point, accent] of [
['A', _a, MARKER_A_CSS],
['B', _b, MARKER_B_CSS],
])
if (point)
entries.push(
createDirectionsMarkerOverlayEntry(
letter,
markerLabelPosition(point),
accent,
),
);
_overlayHost.setEntries(
DIRECTIONS_MARKER_OVERLAY_SOURCE_ID,
entries,
DIRECTIONS_MARKER_OVERLAY_SOURCE_OPTIONS,
);
}
function removeEntity(entity) { function removeEntity(entity) {
if (entity && _viewer && !_viewer.isDestroyed?.()) if (entity && _viewer && !_viewer.isDestroyed?.())
_viewer.entities.remove(entity); _viewer.entities.remove(entity);
@@ -1021,6 +1099,17 @@ export function createDirectionsLayer({ services, source }) {
removeEntity(_markerB); removeEntity(_markerB);
_markerB = markerEntity('B', point, MARKER_B_COLOR); _markerB = markerEntity('B', point, MARKER_B_COLOR);
} }
publishMarkerLabels();
if (_surfaceHeights.has(point)) return;
// The letter appears when its floor cell lands, even under a still camera.
services.ground
.resolveGroundFloorCells([{ lat: point.lat, lon: point.lon }])
.catch(() => {})
.then(() => {
_floorSettled.add(point);
if (_viewer && (point === _a || point === _b))
services.render.governorRequestRender('directions-place');
});
} }
function clearRouteGraphics() { function clearRouteGraphics() {
@@ -1326,6 +1415,7 @@ export function createDirectionsLayer({ services, source }) {
removeEntity(_markerB); removeEntity(_markerB);
_markerA = null; _markerA = null;
_markerB = null; _markerB = null;
_overlayHost.clearSource(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID);
clearRouteGraphics(); clearRouteGraphics();
services.render.governorRequestRender('directions-clear'); services.render.governorRequestRender('directions-clear');
} }
@@ -1603,6 +1693,7 @@ export function createDirectionsLayer({ services, source }) {
_error = null; _error = null;
_flightStep = null; _flightStep = null;
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false);
_overlayHost.setVisible(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID, false);
services.sprites.restoreSpriteOrder(viewer); services.sprites.restoreSpriteOrder(viewer);
console.log('[Data:Directions] Initialized'); console.log('[Data:Directions] Initialized');
}, },
@@ -1616,6 +1707,7 @@ export function createDirectionsLayer({ services, source }) {
_pointerBlocked = false; _pointerBlocked = false;
_stepPoints.show = true; _stepPoints.show = true;
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true);
_overlayHost.setVisible(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID, true);
_installClickHandler(viewer); _installClickHandler(viewer);
services.picking.registerPickOwner( services.picking.registerPickOwner(
'directions', 'directions',
@@ -1634,6 +1726,7 @@ export function createDirectionsLayer({ services, source }) {
_enabled = false; _enabled = false;
clearAll(); clearAll();
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false);
_overlayHost.setVisible(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID, false);
_removeClickHandler(); _removeClickHandler();
services.picking.unregisterPickOwner('directions'); services.picking.unregisterPickOwner('directions');
if (_stepPoints) _stepPoints.show = false; if (_stepPoints) _stepPoints.show = false;
@@ -1740,6 +1833,7 @@ export function createDirectionsLayer({ services, source }) {
_stepPoints = null; _stepPoints = null;
} }
_overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID); _overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID);
_overlayHost.clearSource(DIRECTIONS_MARKER_OVERLAY_SOURCE_ID);
_viewer = null; _viewer = null;
}, },
+1 -14
View File
@@ -4,11 +4,6 @@ import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { parsers } from 'prettier/plugins/babel.mjs'; import { parsers } from 'prettier/plugins/babel.mjs';
// Existing entity graphics only. Remove entries as these callers migrate.
const legacyFiles = new Set([
'src/scenes/dataPacks/presentation.js',
'src/layers/directions/index.js',
]);
const root = fileURLToPath(new URL('../', import.meta.url)); const root = fileURLToPath(new URL('../', import.meta.url));
const propertyName = (node) => node?.name ?? node?.value; const propertyName = (node) => node?.name ?? node?.value;
const memberName = (node) => const memberName = (node) =>
@@ -110,7 +105,6 @@ function cesiumLabels(source) {
test('runtime world text uses the world-overlay host, never new Cesium labels', () => { test('runtime world text uses the world-overlay host, never new Cesium labels', () => {
const violations = []; const violations = [];
const legacySeen = new Set();
function scan(directory) { function scan(directory) {
for (const entry of readdirSync(root + directory, { for (const entry of readdirSync(root + directory, {
withFileTypes: true, withFileTypes: true,
@@ -123,9 +117,7 @@ test('runtime world text uses the world-overlay host, never new Cesium labels',
!/\.(?:test|spec)\.js$/.test(entry.name) !/\.(?:test|spec)\.js$/.test(entry.name)
) { ) {
const hits = cesiumLabels(readFileSync(root + file, 'utf8')); const hits = cesiumLabels(readFileSync(root + file, 'utf8'));
if (!hits.length) continue; violations.push(...hits.map((hit) => `${file}:${hit}`));
if (legacyFiles.has(file)) legacySeen.add(file);
else violations.push(...hits.map((hit) => `${file}:${hit}`));
} }
} }
} }
@@ -135,11 +127,6 @@ test('runtime world text uses the world-overlay host, never new Cesium labels',
[], [],
`Cesium text labels are forbidden; use the world-overlay host.\n${violations.join('\n')}`, `Cesium text labels are forbidden; use the world-overlay host.\n${violations.join('\n')}`,
); );
assert.deepEqual(
legacySeen,
legacyFiles,
'Remove stale or false-positive Cesium-label exceptions',
);
}); });
test('the guard catches label APIs and entity graphics, including aliases and multiline options', () => { test('the guard catches label APIs and entity graphics, including aliases and multiline options', () => {
+5 -2
View File
@@ -2,11 +2,14 @@ import { createDataPackSession } from '../../director/packs/session.js';
import { createPackPresentations } from './presentation.js'; import { createPackPresentations } from './presentation.js';
/** Bind selected shot packs to one viewer and expose copied lifecycle diagnostics. */ /** Bind selected shot packs to one viewer and expose copied lifecycle diagnostics. */
export function createSceneDataPacks(viewer, { sources = {} } = {}) { export function createSceneDataPacks(
viewer,
{ sources = {}, overlayHost = null } = {},
) {
const targets = new Map(); const targets = new Map();
const session = createDataPackSession({ const session = createDataPackSession({
sources, sources,
adapters: createPackPresentations(viewer, targets), adapters: createPackPresentations(viewer, targets, { overlayHost }),
}); });
return { return {
...session, ...session,
+51 -8
View File
@@ -5,11 +5,20 @@ import { applicationPinia } from '../../ui/vueRuntime.js';
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p); const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
const MEDIA_MARKER_CSS = '#ffff00';
// Key every panel and card on the page, so a new one never reuses an old // Key every panel and card on the page, so a new one never reuses an old
// one's DOM. // one's DOM.
let panelKeys = 0, let panelKeys = 0,
cardKeys = 0; cardKeys = 0;
// Label overlay sources, one per pack resource while it shows text, so two
// presentations of one pack never share or clear each other's labels. The host
// keeps a source once made, so a disposed resource returns its id for reuse
// and the count stays at the most labelled resources ever shown at once.
let labelSources = 0;
const freeLabelSources = [];
/** /**
* Create rendering adapters; data acquisition and authored placement stay * Create rendering adapters; data acquisition and authored placement stay
* independent. Each pack's attribution card is presented to the data packs * independent. Each pack's attribution card is presented to the data packs
@@ -18,7 +27,7 @@ let panelKeys = 0,
export function createPackPresentations( export function createPackPresentations(
viewer, viewer,
targets = new Map(), targets = new Map(),
{ store = useDataPacksStore(applicationPinia()) } = {}, { store = useDataPacksStore(applicationPinia()), overlayHost = null } = {},
) { ) {
const owner = {}; const owner = {};
let key, let key,
@@ -56,8 +65,10 @@ export function createPackPresentations(
const entities = [], const entities = [],
primitives = [], primitives = [],
materials = [], materials = [],
urls = []; urls = [],
labels = [];
const featureKeys = []; const featureKeys = [];
let labelSource = null;
let card, let card,
destroyed = false; destroyed = false;
return { return {
@@ -72,6 +83,21 @@ export function createPackPresentations(
viewer.scene.requestRender(); viewer.scene.requestRender();
return entity; return entity;
}, },
/**
* World text for this pack, as a label-overlay entry in the pack's own
* source. The application passes the shared host; there is no text
* without one, so a pack that needs it fails to load instead.
*/
label(entry) {
if (!overlayHost) throw new Error('Pack labels need an overlay host');
// A late adapter writing after disposal must not take a source again.
if (destroyed) return;
labelSource ||=
freeLabelSources.pop() ?? `data-pack-labels:${++labelSources}`;
labels.push(entry);
overlayHost.setEntries(labelSource, labels.slice());
viewer.scene.requestRender();
},
primitive(value) { primitive(value) {
primitives.push(viewer.scene.primitives.add(value)); primitives.push(viewer.scene.primitives.add(value));
if (value.appearance?.material) if (value.appearance?.material)
@@ -98,6 +124,12 @@ export function createPackPresentations(
// The view stops a removed card's player as it goes. // The view stops a removed card's player as it goes.
if (card) removeCard(card); if (card) removeCard(card);
for (const entity of entities.splice(0)) viewer.entities.remove(entity); for (const entity of entities.splice(0)) viewer.entities.remove(entity);
if (labelSource) {
labels.length = 0;
overlayHost.clearSource(labelSource);
freeLabelSources.push(labelSource);
labelSource = null;
}
for (const primitive of primitives.splice(0)) for (const primitive of primitives.splice(0))
viewer.scene.primitives.remove(primitive); viewer.scene.primitives.remove(primitive);
for (const material of materials.splice(0)) { for (const material of materials.splice(0)) {
@@ -200,16 +232,27 @@ export function createPackPresentations(
(item) => item.id === pack.placement.anchorId, (item) => item.id === pack.placement.anchorId,
); );
if (!anchor) throw new Error('Missing media anchor'); if (!anchor) throw new Error('Missing media anchor');
const position = xyz([anchor.lon, anchor.lat, anchor.alt]);
handle.add({ handle.add({
name: pack.id, name: pack.id,
position: xyz([anchor.lon, anchor.lat, anchor.alt]), position,
point: { pixelSize: 16, color: Cesium.Color.YELLOW }, point: {
label: { pixelSize: 16,
text: pack.id, color: Cesium.Color.fromCssColorString(MEDIA_MARKER_CSS),
font: '14px sans-serif',
pixelOffset: new Cesium.Cartesian2(0, -24),
}, },
}); });
handle.label({
id: 'media',
position,
variant: 'label',
title: pack.id,
accent: MEDIA_MARKER_CSS,
protected: true,
interactive: false,
gapPx: 15,
verticalOnly: true,
placement: 'above',
});
handle.media({ handle.media({
kind: asset.mimeType.startsWith('video/') ? 'video' : 'audio', kind: asset.mimeType.startsWith('video/') ? 'video' : 'audio',
src: handle.url(new Blob([asset.bytes], { type: asset.mimeType })), src: handle.url(new Blob([asset.bytes], { type: asset.mimeType })),
+107
View File
@@ -55,3 +55,110 @@ test('abort releases an in-flight image URL before the decoder settles or the vi
URL.revokeObjectURL = priorRevoke; URL.revokeObjectURL = priorRevoke;
} }
}); });
test('a media pack names its marker through the label overlay, cleared on dispose', async () => {
const published = [];
const cleared = [];
const added = [];
const viewer = {
container: {},
scene: { requestRender() {} },
entities: {
add: (spec) => (added.push(spec), spec),
remove() {},
},
};
const overlayHost = {
setEntries: (source, entries) => published.push([source, entries]),
clearSource: (source) => cleared.push(source),
};
const handle = await createPackPresentations(viewer, new Map(), {
overlayHost,
}).media({
pack: {
id: 'harbour-audio',
placement: { anchorId: 'pier' },
attribution: { text: 'Port', license: 'CC-BY' },
},
anchors: [{ id: 'pier', lon: -122.39, lat: 37.79, alt: 5 }],
asset: { bytes: new Uint8Array(4), mimeType: 'audio/mpeg' },
signal: new AbortController().signal,
});
assert.equal(added.length, 1);
assert.equal(added[0].label, undefined, 'no Cesium label on the marker');
const [source, entries] = published.at(-1);
assert.match(source, /^data-pack-labels:\d+$/);
assert.deepEqual(
entries.map(({ title, variant }) => ({ title, variant })),
[{ title: 'harbour-audio', variant: 'label' }],
);
assert.equal(entries[0].position, added[0].position);
assert.equal(entries[0].protected, true, 'it always draws, as before');
handle.dispose();
assert.deepEqual(cleared, [source]);
});
test('without an overlay host a media pack fails to load instead of losing its name', async () => {
const removed = [];
const viewer = {
container: {},
scene: { requestRender() {} },
entities: { add: (spec) => spec, remove: (entity) => removed.push(entity) },
};
await assert.rejects(
createPackPresentations(viewer).media({
pack: {
id: 'harbour-audio',
placement: { anchorId: 'pier' },
attribution: { text: 'Port', license: 'CC-BY' },
},
anchors: [{ id: 'pier', lon: -122.39, lat: 37.79, alt: 5 }],
asset: { bytes: new Uint8Array(4), mimeType: 'audio/mpeg' },
signal: new AbortController().signal,
}),
/overlay host/,
);
assert.equal(removed.length, 1, 'the marker it placed is removed again');
});
test('two presentations of one media pack keep their labels apart, and reuse sources', async () => {
// Like the real host, clearing a source empties it but keeps it registered.
const sources = new Map();
const overlayHost = {
setEntries: (source, entries) => sources.set(source, entries),
clearSource: (source) => sources.set(source, []),
};
const shown = () =>
[...sources.values()].filter((entries) => entries.length).length;
const viewer = {
container: {},
scene: { requestRender() {} },
entities: { add: (spec) => spec, remove() {} },
};
const show = () =>
createPackPresentations(viewer, new Map(), { overlayHost }).media({
pack: {
id: 'harbour-audio',
placement: { anchorId: 'pier' },
attribution: { text: 'Port', license: 'CC-BY' },
},
anchors: [{ id: 'pier', lon: -122.39, lat: 37.79, alt: 5 }],
asset: { bytes: new Uint8Array(4), mimeType: 'audio/mpeg' },
signal: new AbortController().signal,
});
const first = await show();
const second = await show();
assert.equal(shown(), 2);
first.dispose();
assert.equal(shown(), 1, "the second presentation's label stays");
second.dispose();
assert.equal(shown(), 0);
const registered = sources.size;
for (let i = 0; i < 5; i++) (await show()).dispose();
assert.equal(sources.size, registered, 'repeat loads reuse freed sources');
const late = await show();
late.dispose();
late.label({ id: 'late', position: { x: 1, y: 0, z: 0 }, title: 'late' });
late.dispose();
assert.equal(shown(), 0, 'a label after dispose is ignored');
});
+10 -2
View File
@@ -10,6 +10,8 @@ import { useDataPacksStore } from '../../ui/stores/dataPacks.js';
import DataPacks from '../../ui/views/panels/DataPacks.vue'; import DataPacks from '../../ui/views/panels/DataPacks.vue';
import { createPackPresentations } from './presentation.js'; import { createPackPresentations } from './presentation.js';
const overlayHost = { setEntries() {}, clearSource() {} };
const ANCHOR = { id: 'here', lat: 30, lon: -97, alt: 10 }; const ANCHOR = { id: 'here', lat: 30, lon: -97, alt: 10 };
const pack = (id, url) => ({ const pack = (id, url) => ({
id, id,
@@ -29,7 +31,10 @@ function fixture(t) {
entities: { add: (spec) => spec, remove() {} }, entities: { add: (spec) => spec, remove() {} },
}; };
const store = useDataPacksStore(pinia); const store = useDataPacksStore(pinia);
const presentations = createPackPresentations(viewer, new Map(), { store }); const presentations = createPackPresentations(viewer, new Map(), {
store,
overlayHost,
});
t.after(() => app.unmount()); t.after(() => app.unmount());
const settle = async () => { const settle = async () => {
for (let flush = 0; flush < 4; flush += 1) await nextTick(); for (let flush = 0; flush < 4; flush += 1) await nextTick();
@@ -118,7 +123,10 @@ test('each set of presentations keeps its own panel', async (t) => {
const { presentations, viewer, store, settle, $$ } = fixture(t); const { presentations, viewer, store, settle, $$ } = fixture(t);
const one = await media(presentations, 'one'); const one = await media(presentations, 'one');
await media(presentations, 'two'); await media(presentations, 'two');
const later = createPackPresentations(viewer, new Map(), { store }); const later = createPackPresentations(viewer, new Map(), {
store,
overlayHost,
});
const three = await media(later, 'three'); const three = await media(later, 'three');
await settle(); await settle();
const cards = () => const cards = () =>