refactor(cctv): isolate native rendering resources

Keep shared CCTV logic independent of Cesium, release the original renderer's resources, and guard retired initialization/media work. Preserve native marker, plane, volume, calibration and input behavior.

Record keyless dev/preview validation and retain unresolved shell idle/terrain and photoreal acceptance gaps. Routine QA builds use empty Google and Cesium credentials and block paid maps before navigation.
This commit is contained in:
German Khokhlov committed 2026-10-29 00:06:22 +04:00
1 parent f72bf6a774
commit 93c71ffc86
32 files changed
+1536 -740

No files matched your search

+5
View File
@@ -542,6 +542,11 @@
"src/layers/cctv/projectionScheduling.test.mjs", "src/layers/cctv/projectionScheduling.test.mjs",
"src/layers/cctv/gizmo.test.mjs", "src/layers/cctv/gizmo.test.mjs",
"src/layers/cctv/render/cesiumGizmo.js", "src/layers/cctv/render/cesiumGizmo.js",
"src/layers/cctv/render/cesium.js",
"src/layers/cctv/render/cesiumViewshed.js",
"src/layers/cctv/render/cesium.test.mjs",
"src/layers/cctv/lifecycle.test.mjs",
"src/layers/cctv/projectionMedia.test.mjs",
"src/layers/cctv/cards.js", "src/layers/cctv/cards.js",
"src/layers/cctv/rendering.js", "src/layers/cctv/rendering.js",
"src/layers/cctv/testing.js", "src/layers/cctv/testing.js",
+6 -8
View File
@@ -1449,10 +1449,6 @@
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/data/cctvLod.js", "src/data/cctvLod.js",
"src/data/cctvViewshed.js", "src/data/cctvViewshed.js",
"src/data/iconOrientation.js",
"src/data/render/cesiumProjection.js",
"src/data/trackingClickGesture.js",
"src/data/render/cesiumConstants.js",
"src/layers/cctv/calibration.js", "src/layers/cctv/calibration.js",
"src/layers/cctv/cards.js", "src/layers/cctv/cards.js",
"src/layers/cctv/catalog.js", "src/layers/cctv/catalog.js",
@@ -1497,11 +1493,9 @@
"src/geo/scaleToGeodeticSurface.js", "src/geo/scaleToGeodeticSurface.js",
"src/geo/check.js", "src/geo/check.js",
"src/geo/debug.js", "src/geo/debug.js",
"src/geo/developerError.js", "src/geo/developerError.js"
"src/globe/contract.js",
"src/globe/cesium/engineFromViewer.js"
], ],
"external": ["hls.js", "cesium"] "external": ["hls.js"]
}, },
"street-level-layer": { "street-level-layer": {
"exports": [ "exports": [
@@ -1835,6 +1829,8 @@
"src/app/layers/bikeshare.js", "src/app/layers/bikeshare.js",
"src/app/layers/cctv.js", "src/app/layers/cctv.js",
"src/layers/cctv/render/cesiumGizmo.js", "src/layers/cctv/render/cesiumGizmo.js",
"src/layers/cctv/render/cesium.js",
"src/layers/cctv/render/cesiumViewshed.js",
"src/app/layers/earthquakes.js", "src/app/layers/earthquakes.js",
"src/app/layers/firms.js", "src/app/layers/firms.js",
"src/app/layers/flights.js", "src/app/layers/flights.js",
@@ -2793,6 +2789,8 @@
"src/app/layers/bikeshare.js", "src/app/layers/bikeshare.js",
"src/app/layers/cctv.js", "src/app/layers/cctv.js",
"src/layers/cctv/render/cesiumGizmo.js", "src/layers/cctv/render/cesiumGizmo.js",
"src/layers/cctv/render/cesium.js",
"src/layers/cctv/render/cesiumViewshed.js",
"src/app/layers/earthquakes.js", "src/app/layers/earthquakes.js",
"src/app/layers/firms.js", "src/app/layers/firms.js",
"src/app/layers/flights.js", "src/app/layers/flights.js",
+77 -3
View File
@@ -68,6 +68,8 @@
* is no longer needed to avoid a plane/wireframe disagreement. * is no longer needed to avoid a plane/wireframe disagreement.
* *
* Run: node scripts/qa-cctv-v2.mjs --url <explicit-server-url> --out <evidence-directory> [--fixture] [--preview] * Run: node scripts/qa-cctv-v2.mjs --url <explicit-server-url> --out <evidence-directory> [--fixture] [--preview]
* Routine keyless builds also pass --terrain-globe: assert zero scene floor
* samples instead of claiming mesh-floor coverage on a globe-only map.
* *
* Exit 0 = all asserts passed. Non-zero = a hard failure. * Exit 0 = all asserts passed. Non-zero = a hard failure.
*/ */
@@ -99,6 +101,9 @@ const output = getOpt('--out');
if (!explicitUrl || !output) if (!explicitUrl || !output)
throw new Error('Explicit --url and --out are required'); throw new Error('Explicit --url and --out are required');
const mapPolicy = { allowPaidMaps: getFlag('--allow-paid-maps') }; const mapPolicy = { allowPaidMaps: getFlag('--allow-paid-maps') };
const terrainGlobe = getFlag('--terrain-globe');
if (terrainGlobe && mapPolicy.allowPaidMaps)
throw new Error('Terrain-globe QA must keep paid maps blocked');
const CCTV_URL = new URL(explicitUrl); const CCTV_URL = new URL(explicitUrl);
if ( if (
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( ['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
@@ -404,6 +409,17 @@ async function main() {
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
await blockPaidQaMaps(page, mapPolicy); await blockPaidQaMaps(page, mapPolicy);
const terrainRequests = new Set();
let terrainActivityAt = 0;
page.on('request', (request) => {
if (new URL(request.url()).pathname !== '/api/terrain/heights') return;
terrainRequests.add(request);
terrainActivityAt = Date.now();
});
for (const event of ['requestfinished', 'requestfailed'])
page.on(event, (request) => {
if (terrainRequests.delete(request)) terrainActivityAt = Date.now();
});
await page.setViewport({ width: 1280, height: 800 }); await page.setViewport({ width: 1280, height: 800 });
watchW1Page(page, evidence, { watchW1Page(page, evidence, {
name: 'cctv-legacy', name: 'cctv-legacy',
@@ -435,6 +451,27 @@ async function main() {
if (layer.enabled) await app.dataManager.setEnabled(id, false); if (layer.enabled) await app.dataManager.setEnabled(id, false);
app.styleManager._setDetectionMode('OFF'); app.styleManager._setDetectionMode('OFF');
}); });
evidence.surface = await page.evaluate(() => {
const app = window.__godsEyeView;
return {
map: app.mapStackController.getActiveId(),
globeVisible: app.viewer.scene.globe.show,
tileset: Boolean(app.tileset),
googleKeyPresent: Boolean(window.__GOOGLE_MAPS_API_KEY__),
photorealAvailable:
app.mapStackController.isStackAvailable('photoreal'),
};
});
evidence.surfaceMode = terrainGlobe ? 'terrain-globe' : 'mesh-floor';
if (terrainGlobe) {
const keyless =
evidence.surface.globeVisible === true &&
!evidence.surface.tileset &&
!evidence.surface.googleKeyPresent &&
!evidence.surface.photorealAvailable;
record('keyless terrain-globe startup', keyless);
if (!keyless) throw new Error('Expected a keyless globe before CCTV QA');
}
// Let the initial fly-to Austin and first tiles settle. // Let the initial fly-to Austin and first tiles settle.
await sleep(4000); await sleep(4000);
@@ -619,7 +656,7 @@ async function main() {
console.log('Waiting for tiles + the one-shot completion pass...'); console.log('Waiting for tiles + the one-shot completion pass...');
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000); const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
let sampleFloorOk = false; let sampleFloorOk = false;
if (tilesSeenLoaded) { if (tilesSeenLoaded && !terrainGlobe) {
sampleFloorOk = await page sampleFloorOk = await page
.waitForFunction( .waitForFunction(
(base) => window.__qaCounters.sampleHeight - base >= 1, (base) => window.__qaCounters.sampleHeight - base >= 1,
@@ -631,8 +668,12 @@ async function main() {
} }
const cFloor = await readCounters(); const cFloor = await readCounters();
record( record(
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)', terrainGlobe
tilesSeenLoaded && sampleFloorOk, ? 'terrain-globe resolves from priors with zero scene ground samples'
: '>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
terrainGlobe
? cFloor.sampleHeight - c0.sampleHeight === 0
: tilesSeenLoaded && sampleFloorOk,
tilesSeenLoaded tilesSeenLoaded
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}` ? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
: 'tilesLoaded never observed within 45s — floor not assessable this run', : 'tilesLoaded never observed within 45s — floor not assessable this run',
@@ -1588,6 +1629,24 @@ async function main() {
}); });
const cBeforeViewshed = await readCounters(); const cBeforeViewshed = await readCounters();
await page.evaluate(() => {
const primitives = window.__godsEyeView.viewer.scene.primitives;
const events = [];
window.__qaViewshedTrace = events;
for (const method of ['add', 'remove']) {
const original = primitives[method].bind(primitives);
primitives[method] = (primitive, ...args) => {
if (primitive?._gevViewshed)
events.push({
method,
camera: primitive._gevViewshed,
at: performance.now(),
stack: new Error().stack,
});
return original(primitive, ...args);
};
}
});
const modeAfterSet = await page.evaluate(() => { const modeAfterSet = await page.evaluate(() => {
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
mod.setParams({ coverageMode: 'viewshed' }); mod.setParams({ coverageMode: 'viewshed' });
@@ -1638,6 +1697,18 @@ async function main() {
mod.setParams({ coverageMode: 'viewshed' }); mod.setParams({ coverageMode: 'viewshed' });
}); });
await sleep(400); await sleep(400);
// NEXT starts an asynchronous footprint-height lookup. Its completion is
// allowed to update geometry; the unchanged 8s idle window starts after it.
const terrainDeadline = Date.now() + 35000;
while (
(terrainRequests.size || Date.now() - terrainActivityAt < 250) &&
Date.now() < terrainDeadline
)
await sleep(50);
const terrainSettled =
terrainRequests.size === 0 && Date.now() - terrainActivityAt >= 250;
record('footprint requests settle before viewshed idle', terrainSettled);
if (!terrainSettled) throw new Error('Footprint requests did not settle');
await page.evaluate(() => { await page.evaluate(() => {
const prims = window.__godsEyeView.viewer.scene.primitives; const prims = window.__godsEyeView.viewer.scene.primitives;
window.__qaViewshedRefs = []; window.__qaViewshedRefs = [];
@@ -1659,6 +1730,9 @@ async function main() {
return { sameCount, sameIdentity, count: now.length }; return { sameCount, sameIdentity, count: now.length };
}); });
const cAfterViewshed = await readCounters(); const cAfterViewshed = await readCounters();
evidence.viewshedTrace = await page.evaluate(
() => window.__qaViewshedTrace,
);
const lateFloorSamples = const lateFloorSamples =
cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight; cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight;
record( record(
+23 -4
View File
@@ -1,4 +1,7 @@
import { createCesiumCctvGizmoRenderer } from '../../layers/cctv/render/cesiumGizmo.js'; import {
createCctvCesiumRenderer,
bindCctvWorldClickGesture,
} from '../../layers/cctv/render/cesium.js';
import { createCctvLayer } from '../../layers/cctv/index.js'; import { createCctvLayer } from '../../layers/cctv/index.js';
import * as credits from '../../data/dataCredits.js'; import * as credits from '../../data/dataCredits.js';
import * as sprites from '../../data/spriteOrder.js'; import * as sprites from '../../data/spriteOrder.js';
@@ -10,12 +13,16 @@ import * as focus from '../../data/focusDeemphasis.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */ /** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationCctv({ surface, source }) { export function createApplicationCctv({
surface,
source,
rendererFactory = createCctvCesiumRenderer,
}) {
const { terrain, groundFloor: ground, meshFloor: mesh } = surface; const { terrain, groundFloor: ground, meshFloor: mesh } = surface;
return createCctvLayer({ const layer = createCctvLayer({
source, source,
rendererFactory,
services: { services: {
createGizmoRenderer: createCesiumCctvGizmoRenderer,
credits, credits,
sprites, sprites,
activation, activation,
@@ -29,4 +36,16 @@ export function createApplicationCctv({ surface, source }) {
render, render,
}, },
}); });
return {
...layer,
bindCctvWorldClickGesture,
// Legacy native helper; production navigation uses the initialized owner.
focusCctvRecord(viewer, record, duration) {
const renderer = viewer
? createCctvCesiumRenderer({ services: {}, state: {} })
: null;
renderer?.attach(viewer);
return layer.focusCctvRecord(renderer, record, duration);
},
};
} }
+10 -1
View File
@@ -305,6 +305,15 @@ test('default COVERAGE refresh materializes the active and visible camera frustu
inserted.push(entity); inserted.push(entity);
return entity; return entity;
}, },
getById(id) {
return inserted.find((entity) => entity.id === id);
},
remove(entity) {
const index = inserted.indexOf(entity);
if (index < 0) return false;
inserted.splice(index, 1);
return true;
},
}, },
}; };
const activeRecord = records.at(-1); const activeRecord = records.at(-1);
@@ -966,7 +975,7 @@ test('CCTV repeated in-world clicks dispatch focus only for the one real activat
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]); assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
assert.match( assert.match(
componentFunctionSource(cctvLayer.init), componentFunctionSource(cctvLayer.init),
/bindCctvWorldClickGesture\(_clickHandler/, /_removeClick = bindClick\(/,
); );
assert.match( assert.match(
componentFunctionSource(cctvLayer.init), componentFunctionSource(cctvLayer.init),
+35 -55
View File
@@ -6,17 +6,17 @@
* *
* Two responsibilities, both pure of layer state: * Two responsibilities, both pure of layer state:
* - Color identity: a stable per-camera hue (golden-angle spaced over the * - Color identity: a stable per-camera hue (golden-angle spaced over the
* id-sorted catalog index) and the derived fill/line Cesium colors. * id-sorted catalog index) and the derived fill/line RGBA colors.
* - Volume geometry: a translucent frustum pyramid primitive built FROM the * - Volume geometry: frustum pyramid buffers built FROM the
* exact frustumCartesians positions the wireframe uses — welded by * exact frustumCartesians positions the wireframe uses — welded by
* construction, never recomputed independently. 5 vertices, 6 triangles, * construction, never recomputed independently. 5 vertices, 6 triangles.
* synchronous build (trivially small), no picking. * The renderer owns primitive creation and picking policy.
* *
* Nothing here queries the scene or persists anything: colors are * Nothing here queries the scene or persists anything: colors are
* presentation-only and volumes are rebuilt only at the moments the frustum * presentation-only and volumes are rebuilt only at the moments the frustum
* wireframe already rewrites (pose edits / visible-set changes). * wireframe already rewrites (pose edits / visible-set changes).
*/ */
import * as Cesium from 'cesium'; import { Rgba } from '../layers/shared/color.js';
/** Golden angle in degrees — maximally spreads consecutive indices around the hue wheel. */ /** Golden angle in degrees — maximally spreads consecutive indices around the hue wheel. */
const GOLDEN_ANGLE_DEG = 137.50776405003785; const GOLDEN_ANGLE_DEG = 137.50776405003785;
@@ -39,19 +39,42 @@ export function cameraHue(index) {
return (i * GOLDEN_ANGLE_DEG) % 360; return (i * GOLDEN_ANGLE_DEG) % 360;
} }
// The same HSL arithmetic as the native reference, including operation order.
function viewshedHsl(hue, saturation, lightness, alpha) {
const high =
lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const low = 2 * lightness - high;
const channel = (h) => {
if (h < 0) h += 1;
if (h > 1) h -= 1;
if (h * 6 < 1) return low + (high - low) * 6 * h;
if (h * 2 < 1) return high;
if (h * 3 < 2) return low + (high - low) * (2 / 3 - h) * 6;
return low;
};
return new Rgba(
channel(hue + 1 / 3),
channel(hue),
channel(hue - 1 / 3),
alpha,
);
}
/** /**
* Derived viewshed colors for a hue: translucent volume fills (idle/active) * Derived viewshed colors for a hue: translucent volume fills (idle/active)
* and wireframe line tints (idle/active), all in the same hue family. * and wireframe line tints (idle/active), all in the same hue family.
* @param {number} hueDeg - Hue in degrees. * @param {number} hueDeg - Hue in degrees.
* @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}} * @returns {{fill: Rgba, fillActive: Rgba, line: Rgba, lineActive: Rgba}}
*/ */
export function viewshedColors(hueDeg) { export function viewshedColors(hueDeg) {
const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360; const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360;
return { return {
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE), fill: viewshedHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE), fillActive: viewshedHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
line: Cesium.Color.fromHsl(hue, 0.9, 0.65, LINE_ALPHA_IDLE), line: viewshedHsl(hue, 0.9, 0.65, LINE_ALPHA_IDLE),
lineActive: Cesium.Color.fromHsl(hue, 0.9, 0.7, LINE_ALPHA_ACTIVE), lineActive: viewshedHsl(hue, 0.9, 0.7, LINE_ALPHA_ACTIVE),
}; };
} }
@@ -61,8 +84,8 @@ export function viewshedColors(hueDeg) {
* the apex + the far cap split into 2 triangles. Pure — this is the ONLY * the apex + the far cap split into 2 triangles. Pure — this is the ONLY
* geometry definition (weld-by-construction with the wireframe, which draws * geometry definition (weld-by-construction with the wireframe, which draws
* its rays/cap from the same 5 Cartesians). * its rays/cap from the same 5 Cartesians).
* @param {{mount: Cesium.Cartesian3, tl: Cesium.Cartesian3, tr: Cesium.Cartesian3, * @param {{mount: Object, tl: Object, tr: Object,
* br: Cesium.Cartesian3, bl: Cesium.Cartesian3}} positions - frustumCartesians shape. * br: Object, bl: Object}} positions - ECEF frustumCartesians shape.
* @returns {{positions: Float64Array, indices: Uint16Array}} * @returns {{positions: Float64Array, indices: Uint16Array}}
*/ */
export function frustumVolumeGeometryData(positions) { export function frustumVolumeGeometryData(positions) {
@@ -85,46 +108,3 @@ export function frustumVolumeGeometryData(positions) {
]); ]);
return { positions: flat, indices }; return { positions: flat, indices };
} }
/**
* Builds the translucent frustum-volume primitive for one camera.
* Synchronous (6 triangles — cheaper than the async geometry pipeline's
* bookkeeping), unlit flat color, both faces visible (the viewer is routinely
* inside or behind a cone), never pickable (clicks fall through to the
* billboard/wireframe/plane, whose pick semantics are established).
* @param {Object} positions - frustumCartesians shape (see frustumVolumeGeometryData).
* @param {Cesium.Color} color - Per-camera fill color (already alpha'd).
* @returns {Cesium.Primitive}
*/
export function createFrustumVolumePrimitive(positions, color) {
const { positions: flat, indices } = frustumVolumeGeometryData(positions);
const geometry = new Cesium.Geometry({
attributes: {
position: new Cesium.GeometryAttribute({
componentDatatype: Cesium.ComponentDatatype.DOUBLE,
componentsPerAttribute: 3,
values: flat,
}),
},
indices,
primitiveType: Cesium.PrimitiveType.TRIANGLES,
boundingSphere: Cesium.BoundingSphere.fromVertices(Array.from(flat)),
});
return new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
geometry,
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(color),
},
}),
appearance: new Cesium.PerInstanceColorAppearance({
flat: true,
translucent: true,
renderState: {
cull: { enabled: false },
},
}),
asynchronous: false,
allowPicking: false,
});
}
+15 -1
View File
@@ -62,7 +62,7 @@ test('cameraHue: consecutive indices separated by >= 80 degrees', () => {
test('viewshedColors: fill/line pairs with the designed alphas', () => { test('viewshedColors: fill/line pairs with the designed alphas', () => {
const colors = viewshedColors(210); const colors = viewshedColors(210);
for (const key of ['fill', 'fillActive', 'line', 'lineActive']) { for (const key of ['fill', 'fillActive', 'line', 'lineActive']) {
assert.ok(colors[key] instanceof Cesium.Color, `${key} must be a Cesium.Color`); assert.ok(['red', 'green', 'blue', 'alpha'].every((field) => Number.isFinite(colors[key][field])), `${key} has finite RGBA channels`);
} }
assert.ok(Math.abs(colors.fill.alpha - 0.12) < 1e-6); assert.ok(Math.abs(colors.fill.alpha - 0.12) < 1e-6);
assert.ok(Math.abs(colors.fillActive.alpha - 0.22) < 1e-6); assert.ok(Math.abs(colors.fillActive.alpha - 0.22) < 1e-6);
@@ -72,6 +72,20 @@ test('viewshedColors: fill/line pairs with the designed alphas', () => {
assert.ok(colors.line.red !== colors.line.green || colors.line.green !== colors.line.blue); assert.ok(colors.line.red !== colors.line.green || colors.line.green !== colors.line.blue);
}); });
test('portable viewshed colours exactly match the native palette across hue wraps', () => {
const palette = { fill: [0.85, 0.6, 0.12], fillActive: [0.85, 0.6, 0.22], line: [0.9, 0.65, 0.85], lineActive: [0.9, 0.7, 1] };
for (let i = -512; i <= 512; i++) {
const degrees = i * 137.50776405003785;
const hue = (((degrees % 360) + 360) % 360) / 360;
const actual = viewshedColors(degrees);
for (const [key, values] of Object.entries(palette)) {
const expected = Cesium.Color.fromHsl(hue, ...values);
for (const field of ['red', 'green', 'blue', 'alpha']) assert.equal(actual[key][field], expected[field], `${degrees}/${key}/${field}`);
assert.equal(actual[key].withAlpha(0.3).alpha, 0.3);
}
}
});
/** Minimal frustumCartesians-shaped fixture. */ /** Minimal frustumCartesians-shaped fixture. */
function positionsFixture() { function positionsFixture() {
return { return {
+6 -2
View File
@@ -9,6 +9,7 @@ import * as annotations from '../annotations/worldAnnotationRenderer.js';
import { createDirectionsLayer } from '../layers/directions/index.js'; import { createDirectionsLayer } from '../layers/directions/index.js';
import { createDirectionsSource } from '../layers/directions/source.js'; import { createDirectionsSource } from '../layers/directions/source.js';
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js'; import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
import { createCctvCesiumRenderer } from '../layers/cctv/render/cesium.js';
import * as input from './inputOwnership.js'; import * as input from './inputOwnership.js';
import { createOverpassAlprSource } from '../layers/alpr/source.js'; import { createOverpassAlprSource } from '../layers/alpr/source.js';
@@ -83,9 +84,12 @@ test('Directions introduces the shared OSM credit when the requested route draws
test('Warendorf webcam display introduces the shared OSM credit', (t) => { test('Warendorf webcam display introduces the shared OSM credit', (t) => {
const v = viewer(t), empty = new Proxy({}, { get: () => noop }); const v = viewer(t), empty = new Proxy({}, { get: () => noop });
const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost }, const renderer = createCctvCesiumRenderer({ state: {}, services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty } });
renderer.attach(v);
t.after(() => renderer.destroy());
const lifecycle = createCctvLifecycle({ state: { _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost },
services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty }, services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty },
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} }); parts: { renderer, selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} });
lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable(); lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable();
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0); assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
}); });
+2 -2
View File
@@ -297,7 +297,7 @@ export function createCalibration({
*/ */
function ensureGizmo() { function ensureGizmo() {
if (layerState._gizmo || !layerState._viewer) return; if (layerState._gizmo || !parts.renderer?.ready) return;
// Both patch callbacks receive the gizmo's PINNED drag record — never // Both patch callbacks receive the gizmo's PINNED drag record — never
// re-resolve the active camera here: a mid-drag voice select or auto-hop // re-resolve the active camera here: a mid-drag voice select or auto-hop
// would route the captured offsets onto a camera with a different basePose. // would route the captured offsets onto a camera with a different basePose.
@@ -306,7 +306,7 @@ export function createCalibration({
? record ? record
: null; : null;
layerState._gizmo = createCalibrationGizmo({ layerState._gizmo = createCalibrationGizmo({
renderer: services.createGizmoRenderer(layerState._viewer), renderer: parts.renderer.createGizmoRenderer(),
getActiveRecord: () => getActiveRecord: () =>
layerState._enabled && layerState._calibrationMode layerState._enabled && layerState._calibrationMode
? parts.selection.getActiveRecord() ? parts.selection.getActiveRecord()
+8 -12
View File
@@ -9,7 +9,6 @@ import {
applyFrameResult, applyFrameResult,
} from '../../data/cctvCards.js'; } from '../../data/cctvCards.js';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { selectCctvLod, applyEvictionGrace } from '../../data/cctvLod.js'; import { selectCctvLod, applyEvictionGrace } from '../../data/cctvLod.js';
import { import {
CCTV_OVERLAY_SOURCE_OPTIONS, CCTV_OVERLAY_SOURCE_OPTIONS,
@@ -35,7 +34,7 @@ export function createCards({ state: layerState, services, parts, source }) {
} = {}) { } = {}) {
layerState._activeCameraCardEnabled = activeCameraCardEnabled === true; layerState._activeCameraCardEnabled = activeCameraCardEnabled === true;
if (layerState._enabled) pushAmbientCardEntries(); if (layerState._enabled) pushAmbientCardEntries();
layerState._viewer?.scene?.requestRender?.(); parts.renderer?.request();
return { activeCameraCardEnabled: layerState._activeCameraCardEnabled }; return { activeCameraCardEnabled: layerState._activeCameraCardEnabled };
} }
@@ -72,8 +71,7 @@ export function createCards({ state: layerState, services, parts, source }) {
function refreshAmbientCards() { function refreshAmbientCards() {
if ( if (
!layerState._enabled || !layerState._enabled ||
!layerState._viewer || !parts.renderer?.ready ||
layerState._viewer.isDestroyed() ||
!layerState._records.length !layerState._records.length
) { ) {
layerState._cctvOverlayHost.setEntries( layerState._cctvOverlayHost.setEntries(
@@ -83,17 +81,15 @@ export function createCards({ state: layerState, services, parts, source }) {
); );
return; return;
} }
const scene = layerState._viewer.scene; const carto = parts.renderer.cameraCartographic();
const carto = layerState._viewer.camera.positionCartographic;
const viewerLat = carto ? geo.math.toDegrees(carto.latitude) : 0; const viewerLat = carto ? geo.math.toDegrees(carto.latitude) : 0;
const viewerLon = carto ? geo.math.toDegrees(carto.longitude) : 0; const viewerLon = carto ? geo.math.toDegrees(carto.longitude) : 0;
// The active camera is excluded from ambient selection ALWAYS (not just // The active camera is excluded from ambient selection ALWAYS (not just
// after its async activation settles). It is published separately in the // after its async activation settles). It is published separately in the
// protected lane below, and grace never applies to it. // protected lane below, and grace never applies to it.
const activeId = layerState._activeCameraId; const activeId = layerState._activeCameraId;
const occluder = horizonOccluder(layerState._viewer.camera); const horizonVisible = parts.renderer.horizonTest();
const width = scene.canvas.clientWidth || scene.canvas.width || 0; const { width, height } = parts.renderer.viewport();
const height = scene.canvas.clientHeight || scene.canvas.height || 0;
const marginX = width * CARD_VIEW_MARGIN; const marginX = width * CARD_VIEW_MARGIN;
const marginY = height * CARD_VIEW_MARGIN; const marginY = height * CARD_VIEW_MARGIN;
@@ -105,8 +101,8 @@ export function createCards({ state: layerState, services, parts, source }) {
let inView = false; let inView = false;
let sx = NaN; let sx = NaN;
let sy = NaN; let sy = NaN;
if (occluder.isPointVisible(record.position)) { if (horizonVisible(record.position)) {
const screen = scene.cartesianToCanvasCoordinates(record.position); const screen = parts.renderer.project(record.position);
if ( if (
screen && screen &&
Number.isFinite(screen.x) && Number.isFinite(screen.x) &&
@@ -325,7 +321,7 @@ export function createCards({ state: layerState, services, parts, source }) {
slot, slot,
applyFrameResult(slot, { ok: !!frame, frame }, Date.now()), applyFrameResult(slot, { ok: !!frame, frame }, Date.now()),
); );
layerState._viewer?.scene?.requestRender?.(); parts.renderer?.request();
}; };
image.onload = () => settle(true); image.onload = () => settle(true);
image.onerror = () => settle(false); image.onerror = () => settle(false);
+2 -2
View File
@@ -127,7 +127,7 @@ export function createFrames({ state: layerState, services, parts, source }) {
) )
return; return;
const planeShowing = !!(runtime.planeEntity?.show && runtime.planeMaterial); const planeShowing = !!(runtime.planeVisible && runtime.planeMaterial);
if (!planeShowing) return; if (!planeShowing) return;
// Only swap when the canvas content actually changed since the last swap. // Only swap when the canvas content actually changed since the last swap.
@@ -141,7 +141,7 @@ export function createFrames({ state: layerState, services, parts, source }) {
if (!buffer) return; if (!buffer) return;
runtime.lastTextureSwapAt = now; runtime.lastTextureSwapAt = now;
runtime.lastSwappedCanvasStamp = runtime.canvasStamp; runtime.lastSwappedCanvasStamp = runtime.canvasStamp;
runtime.planeMaterial.image = buffer; parts.renderer.setPlaneImage(runtime, buffer);
return true; return true;
} }
+21 -80
View File
@@ -1,6 +1,4 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js';
import { import {
PROJECTION_VERT_ASPECT, PROJECTION_VERT_ASPECT,
FRUSTUM_GROUND_CLEARANCE_M, FRUSTUM_GROUND_CLEARANCE_M,
@@ -8,7 +6,6 @@ import {
PROBE_CLEARANCE_M, PROBE_CLEARANCE_M,
COVERAGE_NEIGHBOR_RADIUS_KM, COVERAGE_NEIGHBOR_RADIUS_KM,
COVERAGE_NEIGHBOR_LIMIT, COVERAGE_NEIGHBOR_LIMIT,
IDLE_COVERAGE_COLOR,
PLANE_FOOTPRINT_LIFT_CAP_M, PLANE_FOOTPRINT_LIFT_CAP_M,
} from './policy.js'; } from './policy.js';
@@ -20,7 +17,6 @@ import {
export function createGeometry({ state: layerState, services, parts, source }) { export function createGeometry({ state: layerState, services, parts, source }) {
const { warmGroundFloor, cachedGroundFloor } = services.ground; const { warmGroundFloor, cachedGroundFloor } = services.ground;
const { sampleMeshFloorCells } = services.mesh;
/** /**
* Ground measured under the plane's support points for the record's * Ground measured under the plane's support points for the record's
@@ -346,31 +342,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
if (record.billboard) { if (record.billboard) {
record.billboard.position = positions.mount; record.billboard.position = positions.mount;
} }
if (record.coverageEntities?.length >= 5) { parts.renderer?.updateCoverage(record, positions);
record.coverageEntities[0].polyline.positions = [
positions.mount,
positions.tl,
];
record.coverageEntities[1].polyline.positions = [
positions.mount,
positions.tr,
];
record.coverageEntities[2].polyline.positions = [
positions.mount,
positions.br,
];
record.coverageEntities[3].polyline.positions = [
positions.mount,
positions.bl,
];
record.coverageEntities[4].polyline.positions = [
positions.tl,
positions.tr,
positions.br,
positions.bl,
positions.tl,
];
}
// A live viewshed volume tracks its wireframe: rebuild from the SAME fresh // A live viewshed volume tracks its wireframe: rebuild from the SAME fresh
// positions (weld invariant). Only records currently showing a volume pay // positions (weld invariant). Only records currently showing a volume pay
// this (6 triangles, synchronous — trivial even during slider/gizmo drags). // this (6 triangles, synchronous — trivial even during slider/gizmo drags).
@@ -451,12 +423,12 @@ export function createGeometry({ state: layerState, services, parts, source }) {
if (sampleGround && parts.model.projectionTilesReady()) { if (sampleGround && parts.model.projectionTilesReady()) {
record.groundMeshSampleRequestCount = record.groundMeshSampleRequestCount =
(record.groundMeshSampleRequestCount || 0) + 1; (record.groundMeshSampleRequestCount || 0) + 1;
const viewerCarto = layerState._viewer?.camera?.positionCartographic; const viewerCarto = parts.renderer?.cameraCartographic();
const excludeObjects = [...(record.coverageEntities || [])]; const excludeObjects = [...(record.coverageEntities || [])];
if (record.billboard) excludeObjects.push(record.billboard); if (record.billboard) excludeObjects.push(record.billboard);
if (record.projection?.planeEntity) if (record.projection?.planeEntity)
excludeObjects.push(record.projection.planeEntity); excludeObjects.push(record.projection.planeEntity);
sampleMeshFloorCells(layerState._viewer?.scene, [point], { parts.renderer.sampleMeshFloorCells([point], {
excludeObjects: excludeObjects.filter(Boolean), excludeObjects: excludeObjects.filter(Boolean),
viewerLat: viewerCarto viewerLat: viewerCarto
? geo.math.toDegrees(viewerCarto.latitude) ? geo.math.toDegrees(viewerCarto.latitude)
@@ -530,9 +502,8 @@ export function createGeometry({ state: layerState, services, parts, source }) {
function destroyViewshedVolume(record) { function destroyViewshedVolume(record) {
if (!record) return; if (!record) return;
if (record.viewshedPrimitive && layerState._viewer) { if (record.viewshedPrimitive)
layerState._viewer.scene.primitives.remove(record.viewshedPrimitive); parts.renderer?.removeVolume(record.viewshedPrimitive);
}
record.viewshedPrimitive = null; record.viewshedPrimitive = null;
} }
@@ -550,7 +521,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
function rebuildViewshedVolume(record, isActive) { function rebuildViewshedVolume(record, isActive) {
destroyViewshedVolume(record); destroyViewshedVolume(record);
if ( if (
!layerState._viewer || !parts.renderer?.ready ||
!record?.frustumPositions || !record?.frustumPositions ||
!record.viewshedColors !record.viewshedColors
) )
@@ -558,14 +529,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
const color = isActive const color = isActive
? record.viewshedColors.fillActive ? record.viewshedColors.fillActive
: record.viewshedColors.fill; : record.viewshedColors.fill;
const primitive = createFrustumVolumePrimitive( record.viewshedPrimitive = parts.renderer.createVolume(record, color);
record.frustumPositions,
color,
);
// QA tag: the harness counts viewshed volumes by this marker.
primitive._gevViewshed = record.camera.id;
record.viewshedPrimitive =
layerState._viewer.scene.primitives.add(primitive);
record.viewshedActiveTint = !!isActive; record.viewshedActiveTint = !!isActive;
} }
@@ -611,8 +575,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
function runActivationObstructionProbe(record) { function runActivationObstructionProbe(record) {
record.probeClampRangeM = null; record.probeClampRangeM = null;
const scene = layerState._viewer?.scene; if (!parts.renderer?.ready) return;
if (!scene || typeof scene.pickFromRay !== 'function') return;
const camera = record.camera; const camera = record.camera;
const rangeScale = parts.calibration.normalizeCalibration( const rangeScale = parts.calibration.normalizeCalibration(
camera.calibration, camera.calibration,
@@ -628,11 +591,19 @@ export function createGeometry({ state: layerState, services, parts, source }) {
const { dir } = frustumFrameEcef(camera, mountPos); const { dir } = frustumFrameEcef(camera, mountPos);
// Exclude everything the layer itself draws so the probe can only hit the // Exclude everything the layer itself draws so the probe can only hit the
// world (3D tiles), not our own billboards/polylines/planes. // world (3D tiles), not our own billboards/polylines/planes.
const exclude = [layerState._billboards, ...layerState._coverageEntities]; const exclude = [...layerState._coverageEntities];
for (const runtime of layerState._projectionEntities) { for (const runtime of layerState._projectionEntities) {
if (runtime?.planeEntity) exclude.push(runtime.planeEntity); if (runtime?.planeEntity) exclude.push(runtime.planeEntity);
} }
const hit = scene.pickFromRay(new Cesium.Ray(mountPos, dir), exclude); for (const other of layerState._records) {
if (other.viewshedPrimitive) exclude.push(other.viewshedPrimitive);
}
const hit = parts.renderer.obstructionRay(
mountPos,
dir,
exclude,
camera.rangeM,
);
if (!hit?.position) return; if (!hit?.position) return;
const dist = geo.cartesian3.distance(mountPos, hit.position); const dist = geo.cartesian3.distance(mountPos, hit.position);
record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist); record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist);
@@ -684,37 +655,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
record.position = positions.mount; record.position = positions.mount;
} }
const addPolyline = (role, linePositions) => return parts.renderer.createCoverage(camera.id, positions);
layerState._viewer.entities.add({
id: `cctv-${camera.id}-${role}`,
properties: { cctvCameraId: camera.id },
polyline: {
positions: linePositions,
width: 1.2,
material: IDLE_COVERAGE_COLOR,
},
});
const entities = [
addPolyline('ray-tl', [positions.mount, positions.tl]),
addPolyline('ray-tr', [positions.mount, positions.tr]),
addPolyline('ray-br', [positions.mount, positions.br]),
addPolyline('ray-bl', [positions.mount, positions.bl]),
addPolyline('cap', [
positions.tl,
positions.tr,
positions.br,
positions.bl,
positions.tl,
]),
];
entities[0]._coverageRole = 'edge';
entities[1]._coverageRole = 'edge';
entities[2]._coverageRole = 'edge';
entities[3]._coverageRole = 'edge';
entities[4]._coverageRole = 'cap';
return entities;
} }
/** /**
@@ -797,9 +738,9 @@ export function createGeometry({ state: layerState, services, parts, source }) {
*/ */
function destroyCoverageEntities() { function destroyCoverageEntities() {
if (!layerState._viewer) return; if (!parts.renderer?.ready) return;
for (const entity of layerState._coverageEntities) { for (const entity of layerState._coverageEntities) {
layerState._viewer.entities.remove(entity); parts.renderer.removeCoverageEntity(entity);
} }
layerState._coverageEntities = []; layerState._coverageEntities = [];
+3 -3
View File
@@ -196,7 +196,7 @@ export function createGeometryQueue({
function processGeometryBatch() { function processGeometryBatch() {
layerState._geoQueueTimer = 0; layerState._geoQueueTimer = 0;
if (!layerState._viewer) { if (!parts.renderer?.ready) {
stopGeometryLoadQueue(); stopGeometryLoadQueue();
return; return;
} }
@@ -209,7 +209,7 @@ export function createGeometryQueue({
const batchResult = processCctvGeometryDrainBatch({ const batchResult = processCctvGeometryDrainBatch({
queue: layerState._geoQueue, queue: layerState._geoQueue,
readOwnership: () => ({ readOwnership: () => ({
trackedEntity: layerState._viewer.trackedEntity, trackedEntity: parts.renderer.following(),
cockpitActive: cockpitActive:
typeof document !== 'undefined' && typeof document !== 'undefined' &&
document.body?.classList.contains('cockpit-mode'), document.body?.classList.contains('cockpit-mode'),
@@ -296,7 +296,7 @@ export function createGeometryQueue({
layerState._tilesReadyReenqueued = false; layerState._tilesReadyReenqueued = false;
if (!layerState._records.length) return; if (!layerState._records.length) return;
const active = parts.selection.getActiveRecord(); const active = parts.selection.getActiveRecord();
const carto = layerState._viewer?.camera?.positionCartographic; const carto = parts.renderer?.cameraCartographic();
const refLat = carto const refLat = carto
? geo.math.toDegrees(carto.latitude) ? geo.math.toDegrees(carto.latitude)
: (active?.camera.lat ?? 0); : (active?.camera.lat ?? 0);
+2 -2
View File
@@ -43,7 +43,7 @@ export function createGround({ state: layerState, services, parts, source }) {
*/ */
function currentSurfaceRegime() { function currentSurfaceRegime() {
return surfaceRegimeKey(layerState._viewer?.scene?.globe?.show); return surfaceRegimeKey(parts.renderer?.globeVisible());
} }
/** /**
@@ -244,7 +244,7 @@ export function createGround({ state: layerState, services, parts, source }) {
*/ */
function handleMapStackChanged() { function handleMapStackChanged() {
if (!layerState._viewer || !layerState._records.length) return; if (!parts.renderer?.ready || !layerState._records.length) return;
const regime = currentSurfaceRegime(); const regime = currentSurfaceRegime();
if (regime === layerState._lastAppliedRegime) return; if (regime === layerState._lastAppliedRegime) return;
layerState._lastAppliedRegime = regime; layerState._lastAppliedRegime = regime;
+2 -2
View File
@@ -25,13 +25,13 @@ export function createHover({ state: layerState, services, parts, source }) {
!position !position
) )
return; return;
if (!layerState._viewer || layerState._viewer.isDestroyed()) return; if (!parts.renderer?.ready) return;
const now = Date.now(); const now = Date.now();
if (now - layerState._hoverLastPickAt < HOVER_PICK_THROTTLE_MS) return; if (now - layerState._hoverLastPickAt < HOVER_PICK_THROTTLE_MS) return;
layerState._hoverLastPickAt = now; layerState._hoverLastPickAt = now;
let picked = null; let picked = null;
try { try {
picked = layerState._viewer.scene.pick(position); picked = parts.renderer.pick(position);
} catch { } catch {
picked = null; picked = null;
} }
+2 -3
View File
@@ -20,7 +20,7 @@ import { createIngestion } from './ingestion.js';
import { createState } from './state.js'; import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */ /** Construct one layer with its own scene state and supplied application services. */
export function createCctvLayer({ services, source }) { export function createCctvLayer({ services, source, rendererFactory }) {
if ( if (
!['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'].every( !['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'].every(
(key) => typeof source?.[key] === 'function', (key) => typeof source?.[key] === 'function',
@@ -29,7 +29,7 @@ export function createCctvLayer({ services, source }) {
throw new TypeError('A camera source is required'); throw new TypeError('A camera source is required');
const state = createState({ services }); const state = createState({ services });
const parts = {}; const parts = {};
const context = { state, services, parts, source }; const context = { state, services, parts, source, rendererFactory };
parts.calibration = createCalibration(context); parts.calibration = createCalibration(context);
parts.testing = createTesting(context); parts.testing = createTesting(context);
parts.projection = createProjection(context); parts.projection = createProjection(context);
@@ -94,7 +94,6 @@ export function createCctvLayer({ services, source }) {
clearProbeClampOnDeactivation: clearProbeClampOnDeactivation:
parts.geometry.clearProbeClampOnDeactivation, parts.geometry.clearProbeClampOnDeactivation,
cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation, cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation,
bindCctvWorldClickGesture: parts.selection.bindCctvWorldClickGesture,
setActiveCamera: parts.selection.setActiveCamera, setActiveCamera: parts.selection.setActiveCamera,
deactivateActiveCamera: parts.selection.deactivateActiveCamera, deactivateActiveCamera: parts.selection.deactivateActiveCamera,
cctvEmptyClickDeselects: parts.selection.cctvEmptyClickDeselects, cctvEmptyClickDeselects: parts.selection.cctvEmptyClickDeselects,
+278 -301
View File
@@ -1,11 +1,9 @@
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { viewshedColors, cameraHue } from '../../data/cctvViewshed.js'; import { viewshedColors, cameraHue } from '../../data/cctvViewshed.js';
import { CCTV_OVERLAY_SOURCE_ID } from '../../data/cctvCards.js'; import { CCTV_OVERLAY_SOURCE_ID } from '../../data/cctvCards.js';
import { GIZMO_ID_PREFIX } from '../../data/cctvGizmo.js'; import { GIZMO_ID_PREFIX } from '../../data/cctvGizmo.js';
import { import {
GROUND_PRIOR_INIT_WAIT_MS, GROUND_PRIOR_INIT_WAIT_MS,
CAMERA_ICON,
IDLE_CAMERA_COLOR,
CALIBRATION_RANGE_FLOOR_M, CALIBRATION_RANGE_FLOOR_M,
} from './policy.js'; } from './policy.js';
@@ -14,8 +12,8 @@ export function createLifecycle({
services, services,
parts, parts,
source, source,
rendererFactory,
}) { }) {
const { registerSpriteCollection, restoreSpriteOrder } = services.sprites;
const { activateCctvCameraFromWorldClick } = services.activation; const { activateCctvCameraFromWorldClick } = services.activation;
const { resolvePickId, registerPickOwner, unregisterPickOwner } = const { resolvePickId, registerPickOwner, unregisterPickOwner } =
services.picking; services.picking;
@@ -38,9 +36,6 @@ export function createLifecycle({
layerState._lastError = null; layerState._lastError = null;
layerState._lastFocusStyleAt = 0; layerState._lastFocusStyleAt = 0;
layerState._activeFocusStyleCount = 0; layerState._activeFocusStyleCount = 0;
// FIX ①/③: the discovered tileset handle is scene-scoped — drop it so a fresh
// init re-discovers against the current scene primitives.
layerState._activeTileset = null;
// Task 5: the applied-regime tracker is record-set-scoped — a fresh init // Task 5: the applied-regime tracker is record-set-scoped — a fresh init
// recomputes it against the then-current scene. // recomputes it against the then-current scene.
layerState._lastAppliedRegime = null; layerState._lastAppliedRegime = null;
@@ -52,283 +47,277 @@ export function createLifecycle({
* handling, and performs initial health sync. Coverage entities stay lazy. * handling, and performs initial health sync. Coverage entities stay lazy.
* @param {Cesium.Viewer} viewer - The Cesium viewer instance. * @param {Cesium.Viewer} viewer - The Cesium viewer instance.
*/ */
async init(viewer) { async init(viewer, options = {}) {
layerState._sourceAbort?.abort(); if (parts.renderer) throw new Error('CCTV layer is already initialized');
const sourceAbort = new AbortController(); if (typeof rendererFactory !== 'function')
layerState._sourceAbort = sourceAbort; throw new TypeError('A CCTV renderer factory is required');
if (typeof document !== 'undefined') const renderer = rendererFactory({
document.addEventListener( state: layerState,
'visibilitychange', services,
parts.cards.handleVisibilityChange, parts,
source,
engine: options.engine,
});
parts.renderer = renderer;
try {
renderer.attach(viewer);
layerState._sourceAbort?.abort();
const sourceAbort = new AbortController();
layerState._sourceAbort = sourceAbort;
if (typeof document !== 'undefined')
document.addEventListener(
'visibilitychange',
parts.cards.handleVisibilityChange,
);
clearRuntimeState();
layerState._enabled = false;
layerState._activeCameraId = null;
layerState._autoHopSuspended = false;
layerState._lastHopAt = 0;
layerState._lastViewContext = '';
layerState._calibrationById = parts.calibration.loadCalibrationStore();
parts.renderer.createMarkers();
const sources = await parts.catalog.loadCameraSources();
sourceAbort.signal.throwIfAborted();
// prettier-ignore
const catalogFromSources = parts.catalog.buildCatalogFromSources(sources);
const catalog = catalogFromSources.length
? catalogFromSources
: parts.catalog.seedCatalog();
// Viewshed color identity (design §3a): golden-angle hue over the
// id-SORTED catalog index — deterministic across sessions for a stable
// catalog, maximally separated for neighboring cameras.
const hueIndexById = new Map(
catalog
.map((camera) => camera.id)
.sort()
.map((id, index) => [id, index]),
); );
layerState._viewer = viewer;
clearRuntimeState();
layerState._enabled = false;
layerState._activeCameraId = null;
layerState._autoHopSuspended = false;
layerState._lastHopAt = 0;
layerState._lastViewContext = '';
layerState._calibrationById = parts.calibration.loadCalibrationStore();
layerState._billboards = new Cesium.BillboardCollection(); for (const camera of catalog) {
layerState._viewer.scene.primitives.add(layerState._billboards); const savedEntry = layerState._calibrationById.get(camera.id);
registerSpriteCollection('cctv', layerState._billboards); if (savedEntry) {
// Entries saved before the range floor dropped (no rangeFloorM)
const sources = await parts.catalog.loadCameraSources(); // keep their effective range by re-basing rangeScale once.
const catalogFromSources = parts.catalog.buildCatalogFromSources(sources); const values =
const catalog = catalogFromSources.length savedEntry.rangeFloorM === CALIBRATION_RANGE_FLOOR_M
? catalogFromSources ? savedEntry.values
: parts.catalog.seedCatalog(); : parts.calibration.migrateRangeScaleForFloor(
savedEntry.values,
// Viewshed color identity (design §3a): golden-angle hue over the camera.rangeM,
// id-SORTED catalog index — deterministic across sessions for a stable );
// catalog, maximally separated for neighboring cameras. savedEntry.values = values;
const hueIndexById = new Map( savedEntry.rangeFloorM = CALIBRATION_RANGE_FLOOR_M;
catalog camera.calibration = parts.calibration.normalizeCalibration(values);
.map((camera) => camera.id) camera.calSource = savedEntry.source;
.sort() }
.map((id, index) => [id, index]), parts.model.ensureCameraPose(camera);
);
for (const camera of catalog) {
const savedEntry = layerState._calibrationById.get(camera.id);
if (savedEntry) {
// Entries saved before the range floor dropped (no rangeFloorM)
// keep their effective range by re-basing rangeScale once.
const values =
savedEntry.rangeFloorM === CALIBRATION_RANGE_FLOOR_M
? savedEntry.values
: parts.calibration.migrateRangeScaleForFloor(
savedEntry.values,
camera.rangeM,
);
savedEntry.values = values;
savedEntry.rangeFloorM = CALIBRATION_RANGE_FLOOR_M;
camera.calibration = parts.calibration.normalizeCalibration(values);
camera.calSource = savedEntry.source;
} }
parts.model.ensureCameraPose(camera);
}
// Task 5 (height-datum fix): batch ALL camera coords through the Re:Earth // Task 5 (height-datum fix): batch ALL camera coords through the Re:Earth
// ellipsoidal ground-prior resolver (network-cached — NOT a scene query; // ellipsoidal ground-prior resolver (network-cached — NOT a scene query;
// the catalog's orthometric groundElevationM feeds the geoid fallback // the catalog's orthometric groundElevationM feeds the geoid fallback
// chain). Bounded wait: a warm proxy cache resolves in milliseconds, so // chain). Bounded wait: a warm proxy cache resolves in milliseconds, so
// records are normally built WITH their prior (correct first paint in // records are normally built WITH their prior (correct first paint in
// every regime); a cold/slow upstream loses the race and the batch // every regime); a cold/slow upstream loses the race and the batch
// applies post-hoc via applyLateGroundPriors instead of hanging init. // applies post-hoc via applyLateGroundPriors instead of hanging init.
const priorsPromise = parts.ground.resolveGroundPriors(catalog); const priorsPromise = parts.ground.resolveGroundPriors(catalog);
const priors = await Promise.race([ const priors = await Promise.race([
priorsPromise, priorsPromise,
new Promise((resolve) => new Promise((resolve) =>
setTimeout(() => resolve(null), GROUND_PRIOR_INIT_WAIT_MS), setTimeout(() => resolve(null), GROUND_PRIOR_INIT_WAIT_MS),
),
]);
sourceAbort.signal.throwIfAborted();
for (let i = 0; i < catalog.length; i++) {
const camera = catalog[i];
// Ellipsoidal ground prior (or null while the batch is still in
// flight). Geometry falls back to the catalog value only until the
// batch lands.
const groundPrior = priors?.[i] || null;
// Cheap first-pass altitude from the ellipsoidal prior (catalog value
// only as the pre-prior fallback) — the staggered geometry queue
// refines with sampled tile heights after enable so the init path
// never raycasts the scene once per camera.
const priorGround = Number.isFinite(groundPrior?.ellipsoid)
? groundPrior.ellipsoid
: Number(camera.groundElevationM) || 0;
camera.absoluteHeightM = priorGround + camera.mountHeightM;
const position = Cesium.Cartesian3.fromDegrees(
camera.lon,
camera.lat,
camera.absoluteHeightM,
);
const billboard = layerState._billboards.add({
id: camera.id,
image: CAMERA_ICON,
position,
color: IDLE_CAMERA_COLOR,
width: 24,
height: 24,
// Field-test fix (2026-07-06): always-on-top. The old finite value
// (1800 m) re-engaged the depth test at far zoom, where the COARSE
// far-LOD Google-3D mesh sits above the true ground and swallowed
// ground-anchored icons ("submerged" pills over SF). Far-side-of-globe
// icons are handled by refreshHorizonCulling() (the flights-layer
// EllipsoidalOccluder pattern), not by the depth test.
disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: new Cesium.NearFarScalar(350, 1.25, 4_000_000, 0.42),
});
const record = {
camera,
position,
billboard,
coverageEntities: [],
projection: null,
// Task 5 (height-datum fix): regime-aware ground resolution state.
// groundPrior — { ellipsoid, source } from the Re:Earth batch
// (null until a late batch lands). The prior applies in EVERY
// regime and is the terrain-globe resolution outright.
// groundResolved — PER-REGIME one-shot latch (regime key →
// boolean): true once this record's resolution completed for that
// regime; such records are excluded from the completion pass so
// their geometry freezes. Re-armed only on a genuine pose change,
// explicit user select/move, or a surface-regime change — never
// on the 10s timer.
// groundSamples — PER-REGIME resolved ground (regime key →
// metres): the accepted one-shot scene sample in google-3d, the
// mirrored prior in terrain-globe. Kept across re-arms as the
// "has ever resolved" memory for the B9c mid-stream guard.
// frustumPositions — cached Cartesians for pure recomputes (so
// plane placement never re-derives geometry it already has).
groundPrior,
groundResolved: {},
groundSamples: {},
frustumGeometry: null,
frustumPositions: null,
// §9.1 activation obstruction probe result: effective-range clamp so
// the far-cap plane never clips into the tiles. Null = unclamped.
// Reset + re-probed on every activation; cleared when the user takes
// the range slider (slider overrides the clamp).
probeClampRangeM: null,
// Viewshed (design §3a/§3b): per-camera color identity + the volume
// primitive handle (exists only in viewshed mode for the visible set).
viewshedColors: viewshedColors(
cameraHue(hueIndexById.get(camera.id) ?? 0),
), ),
viewshedPrimitive: null, ]);
viewshedActiveTint: false, sourceAbort.signal.throwIfAborted();
};
layerState._records.push(record);
layerState._recordById.set(camera.id, record);
}
layerState._count = layerState._records.length; for (let i = 0; i < catalog.length; i++) {
if (layerState._records.length > 0) { const camera = catalog[i];
// Projection runtime + first frame fetch are deferred to enable() so // Ellipsoidal ground prior (or null while the batch is still in
// initializing the catalog stays render-cheap. // flight). Geometry falls back to the catalog value only until the
layerState._activeCameraId = layerState._records[0].camera.id; // batch lands.
} const groundPrior = priors?.[i] || null;
// Cheap first-pass altitude from the ellipsoidal prior (catalog value
// only as the pre-prior fallback) — the staggered geometry queue
// refines with sampled tile heights after enable so the init path
// never raycasts the scene once per camera.
const priorGround = Number.isFinite(groundPrior?.ellipsoid)
? groundPrior.ellipsoid
: Number(camera.groundElevationM) || 0;
camera.absoluteHeightM = priorGround + camera.mountHeightM;
const position = geo.cartesian3.fromDegrees(
camera.lon,
camera.lat,
camera.absoluteHeightM,
);
const billboard = parts.renderer.createMarker(camera, position);
// Task 5: if the prior batch lost init's bounded race, apply it post-hoc const record = {
// when it lands (pure recomputes — applyLateGroundPriors guards against camera,
// a torn-down/re-inited catalog). position,
if (!priors) { billboard,
const initRecords = layerState._records.slice(); coverageEntities: [],
priorsPromise projection: null,
.then((late) => { // Task 5 (height-datum fix): regime-aware ground resolution state.
if (late) parts.ground.applyLateGroundPriors(initRecords, late); // groundPrior — { ellipsoid, source } from the Re:Earth batch
}) // (null until a late batch lands). The prior applies in EVERY
.catch(() => {}); // regime and is the terrain-globe resolution outright.
} // groundResolved — PER-REGIME one-shot latch (regime key →
// boolean): true once this record's resolution completed for that
// regime; such records are excluded from the completion pass so
// their geometry freezes. Re-armed only on a genuine pose change,
// explicit user select/move, or a surface-regime change — never
// on the 10s timer.
// groundSamples — PER-REGIME resolved ground (regime key →
// metres): the accepted one-shot scene sample in google-3d, the
// mirrored prior in terrain-globe. Kept across re-arms as the
// "has ever resolved" memory for the B9c mid-stream guard.
// frustumPositions — cached Cartesians for pure recomputes (so
// plane placement never re-derives geometry it already has).
groundPrior,
groundResolved: {},
groundSamples: {},
frustumGeometry: null,
frustumPositions: null,
// §9.1 activation obstruction probe result: effective-range clamp so
// the far-cap plane never clips into the tiles. Null = unclamped.
// Reset + re-probed on every activation; cleared when the user takes
// the range slider (slider overrides the clamp).
probeClampRangeM: null,
// Viewshed (design §3a/§3b): per-camera color identity + the volume
// primitive handle (exists only in viewshed mode for the visible set).
viewshedColors: viewshedColors(
cameraHue(hueIndexById.get(camera.id) ?? 0),
),
viewshedPrimitive: null,
viewshedActiveTint: false,
};
layerState._records.push(record);
layerState._recordById.set(camera.id, record);
}
// Task 5: track the surface regime the initial geometry was computed for layerState._count = layerState._records.length;
// and listen for map-stack changes (main.js re-dispatches if (layerState._records.length > 0) {
// MapStackController.onChange as this CustomEvent). The handler compares // Projection runtime + first frame fetch are deferred to enable() so
// regimes itself, so 'switching'/'error' emissions and same-regime stack // initializing the catalog stays render-cheap.
// swaps (bing→osm) no-op. layerState._activeCameraId = layerState._records[0].camera.id;
layerState._lastAppliedRegime = parts.ground.currentSurfaceRegime(); }
if (!layerState._mapStackListener && typeof window !== 'undefined') {
layerState._mapStackListener = () =>
parts.ground.handleMapStackChanged();
window.addEventListener(
'gev:map-stack-changed',
layerState._mapStackListener,
);
}
// Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with // Task 5: if the prior batch lost init's bounded race, apply it post-hoc
// the billboards' always-on-top depth setting) + one initial pass so the // when it lands (pure recomputes — applyLateGroundPriors guards against
// first paint is already culled. // a torn-down/re-inited catalog).
if (!layerState._horizonCullListener) { if (!priors) {
// Ambient cards piggyback the same settle event: moveEnd-driven const initRecords = layerState._records.slice();
// reselection only, never per frame (refreshAmbientCards no-ops while priorsPromise
// the layer is disabled). .then((late) => {
layerState._horizonCullListener = () => { if (late) parts.ground.applyLateGroundPriors(initRecords, late);
layerState._cameraMoving = false;
parts.rendering.refreshHorizonCulling();
parts.cards.refreshAmbientCards();
};
layerState._viewer.camera.moveEnd.addEventListener(
layerState._horizonCullListener,
);
}
if (!layerState._moveStartListener) {
// Item B: hover picking pauses while the camera is in motion.
layerState._moveStartListener = () => {
layerState._cameraMoving = true;
};
layerState._viewer.camera.moveStart.addEventListener(
layerState._moveStartListener,
);
}
parts.rendering.refreshHorizonCulling();
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
layerState._viewer.scene.canvas,
);
parts.selection.bindCctvWorldClickGesture(
layerState._clickHandler,
(click) => {
if (!layerState._enabled) return;
const picked = layerState._viewer.scene.pick(click.position);
const cameraId = parts.selection.extractPickedCameraId(picked);
if (cameraId) {
activateCctvCameraFromWorldClick(
cameraId,
parts.selection.setActiveCamera,
);
return;
}
// Any identified scene object owns this click even if its layer does not
// register a shared pick predicate. This keeps selectable siblings ahead
// of an overlapping CCTV card while ID-less globe/terrain/tile surfaces
// remain eligible for true empty-space deselection.
const pickedId = resolvePickId(picked);
if (pickedId !== null) return;
// Item A (owner round 2): the scene pick found no camera — try the
// painted ambient cards. The cards canvas is pointer-events:none (this
// handler owns the events), so a click landing on a card's rect selects
// its camera exactly like a click on the icon. Cesium click positions
// and the recorded rects are both CSS px — direct comparison.
const cardId = layerState._cctvOverlayHost.hitTest(
click.position.x,
click.position.y,
{ sourceId: CCTV_OVERLAY_SOURCE_ID },
)?.entryId;
if (cardId && layerState._recordById.has(cardId)) {
activateCctvCameraFromWorldClick(
cardId,
parts.selection.setActiveCamera,
);
return;
}
if (
parts.selection.cctvEmptyClickDeselects(picked, {
activeCameraId: layerState._activeCameraId,
calibrationMode: layerState._calibrationMode,
}) })
) { .catch(() => {});
parts.selection.deactivateActiveCamera(); }
}
},
{
// Item B: hover summons a card on a cardless camera icon. The gesture
// classifier owns MOUSE_MOVE too, so chain hover work through its seam
// instead of replacing the travel accumulator's handler.
onMouseMove: (movement) =>
parts.hover.handleHoverMove(movement?.endPosition),
},
);
await parts.health.syncHealthState(true); // Task 5: track the surface regime the initial geometry was computed for
parts.rendering.refreshCoverageStyles(); // and listen for map-stack changes (main.js re-dispatches
parts.presentation.notifyListeners(); // MapStackController.onChange as this CustomEvent). The handler compares
restoreSpriteOrder(layerState._viewer); // regimes itself, so 'switching'/'error' emissions and same-regime stack
console.log('[Data:CCTV] Initialized with', layerState._count, 'cameras'); // swaps (bing→osm) no-op.
layerState._lastAppliedRegime = parts.ground.currentSurfaceRegime();
if (!layerState._mapStackListener && typeof window !== 'undefined') {
layerState._mapStackListener = () =>
parts.ground.handleMapStackChanged();
window.addEventListener(
'gev:map-stack-changed',
layerState._mapStackListener,
);
}
// Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with
// the billboards' always-on-top depth setting) + one initial pass so the
// first paint is already culled.
if (!layerState._horizonCullListener) {
// Ambient cards piggyback the same settle event: moveEnd-driven
// reselection only, never per frame (refreshAmbientCards no-ops while
// the layer is disabled).
layerState._horizonCullListener = parts.renderer.onMoveEnd(() => {
layerState._cameraMoving = false;
parts.rendering.refreshHorizonCulling();
parts.cards.refreshAmbientCards();
});
}
if (!layerState._moveStartListener) {
// Item B: hover picking pauses while the camera is in motion.
layerState._moveStartListener = parts.renderer.onMoveStart(() => {
layerState._cameraMoving = true;
});
}
parts.rendering.refreshHorizonCulling();
layerState._removeClick = parts.renderer.bindClick(
(click) => {
if (!layerState._enabled) return;
const picked = parts.renderer.pick(click.position);
const cameraId = parts.selection.extractPickedCameraId(picked);
if (cameraId) {
activateCctvCameraFromWorldClick(
cameraId,
parts.selection.setActiveCamera,
);
return;
}
// Any identified scene object owns this click even if its layer does not
// register a shared pick predicate. This keeps selectable siblings ahead
// of an overlapping CCTV card while ID-less globe/terrain/tile surfaces
// remain eligible for true empty-space deselection.
const pickedId = resolvePickId(picked);
if (pickedId !== null) return;
// Item A (owner round 2): the scene pick found no camera — try the
// painted ambient cards. The cards canvas is pointer-events:none (this
// handler owns the events), so a click landing on a card's rect selects
// its camera exactly like a click on the icon. Cesium click positions
// and the recorded rects are both CSS px — direct comparison.
const cardId = layerState._cctvOverlayHost.hitTest(
click.position.x,
click.position.y,
{ sourceId: CCTV_OVERLAY_SOURCE_ID },
)?.entryId;
if (cardId && layerState._recordById.has(cardId)) {
activateCctvCameraFromWorldClick(
cardId,
parts.selection.setActiveCamera,
);
return;
}
if (
parts.selection.cctvEmptyClickDeselects(picked, {
activeCameraId: layerState._activeCameraId,
calibrationMode: layerState._calibrationMode,
})
) {
parts.selection.deactivateActiveCamera();
}
},
{
// Item B: hover summons a card on a cardless camera icon. The gesture
// classifier owns MOUSE_MOVE too, so chain hover work through its seam
// instead of replacing the travel accumulator's handler.
onMouseMove: (movement) =>
parts.hover.handleHoverMove(movement?.endPosition),
},
);
await parts.health.syncHealthState(true);
sourceAbort.signal.throwIfAborted();
parts.rendering.refreshCoverageStyles();
parts.presentation.notifyListeners();
parts.renderer.restoreOrder();
// prettier-ignore
console.log('[Data:CCTV] Initialized with', layerState._count, 'cameras');
} catch (error) {
if (parts.renderer === renderer) methods.destroy();
else renderer.destroy();
throw error;
}
}, },
/** /**
@@ -344,7 +333,7 @@ export function createLifecycle({
(record) => record.camera.cityId === 'warendorf', (record) => record.camera.cityId === 'warendorf',
) )
) )
services.credits?.showOsmCredit?.(layerState._viewer, 'cctv'); parts.renderer?.showCredit();
layerState._lastUpdate = Date.now(); layerState._lastUpdate = Date.now();
// Pick-ownership (H2): camera billboards use the camera id directly; // Pick-ownership (H2): camera billboards use the camera id directly;
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids. // coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
@@ -386,12 +375,12 @@ export function createLifecycle({
parts.cards.startCardFrameLoop(); parts.cards.startCardFrameLoop();
parts.cards.refreshAmbientCards(); parts.cards.refreshAmbientCards();
parts.presentation.notifyListeners(); parts.presentation.notifyListeners();
restoreSpriteOrder(layerState._viewer); parts.renderer.restoreOrder();
}, },
/** Disables the layer: hides entities, stops the projection loop and load queue. */ /** Disables the layer: hides entities, stops the projection loop and load queue. */
disable() { disable() {
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv'); parts.renderer?.hideCredit();
layerState._enabled = false; layerState._enabled = false;
unregisterPickOwner('cctv'); unregisterPickOwner('cctv');
// ADJUST mode does not survive a layer toggle — predictable re-entry. // ADJUST mode does not survive a layer toggle — predictable re-entry.
@@ -412,10 +401,10 @@ export function createLifecycle({
/** /**
* Tears down the layer: destroys click handler, projection loop, coverage * Tears down the layer: destroys click handler, projection loop, coverage
* entities, billboards, and clears all runtime state and subscribers. * entities, billboards, and clears all runtime state and subscribers.
* @param {Cesium.Viewer} [viewer] - Viewer instance (falls back to stored ref). * Releases resources through the renderer that originally acquired them.
*/ */
destroy(viewer) { destroy() {
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv'); parts.renderer?.hideCredit();
layerState._sourceAbort?.abort(); layerState._sourceAbort?.abort();
if (typeof document !== 'undefined') if (typeof document !== 'undefined')
document.removeEventListener( document.removeEventListener(
@@ -430,39 +419,27 @@ export function createLifecycle({
); );
layerState._mapStackListener = null; layerState._mapStackListener = null;
} }
const teardownViewer = viewer || layerState._viewer; layerState._horizonCullListener?.();
if (layerState._horizonCullListener && teardownViewer?.camera?.moveEnd) { layerState._horizonCullListener = null;
teardownViewer.camera.moveEnd.removeEventListener( layerState._moveStartListener?.();
layerState._horizonCullListener, layerState._moveStartListener = null;
); layerState._removeFocusAppearListener?.();
layerState._horizonCullListener = null; layerState._removeFocusAppearListener = null;
}
if (layerState._moveStartListener && teardownViewer?.camera?.moveStart) {
teardownViewer.camera.moveStart.removeEventListener(
layerState._moveStartListener,
);
layerState._moveStartListener = null;
}
if (layerState._gizmo) { if (layerState._gizmo) {
layerState._gizmo.destroy(); layerState._gizmo.destroy();
layerState._gizmo = null; layerState._gizmo = null;
} }
layerState._calibrationMode = false; layerState._calibrationMode = false;
releaseContinuousRender('cctv-adjust'); releaseContinuousRender('cctv-adjust');
if (layerState._clickHandler) { layerState._removeClick?.();
layerState._clickHandler.destroy(); layerState._removeClick = null;
layerState._clickHandler = null;
}
parts.projection.stopProjectionLoop(); parts.projection.stopProjectionLoop();
parts.geometryQueue.stopGeometryLoadQueue(); parts.geometryQueue.stopGeometryLoadQueue();
parts.cards.teardownAmbientCards(); parts.cards.teardownAmbientCards();
parts.geometry.destroyCoverageEntities(); parts.geometry.destroyCoverageEntities();
if (layerState._billboards && teardownViewer) { parts.renderer?.destroy();
teardownViewer.scene.primitives.remove(layerState._billboards); parts.renderer = null;
layerState._billboards = null;
}
clearRuntimeState(); clearRuntimeState();
layerState._viewer = null;
layerState._enabled = false; layerState._enabled = false;
layerState._activeCameraId = null; layerState._activeCameraId = null;
layerState._autoHopSuspended = false; layerState._autoHopSuspended = false;
+122
View File
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createLifecycle } from './lifecycle.js';
import { createState } from './state.js';
function setup(t, { failMarkers = false } = {}) {
const noop = () => {};
const services = {
overlays: {
clearOverlaySource: noop,
hitTestWorldOverlay: noop,
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
},
activation: {},
picking: { unregisterPickOwner: noop },
focus: {},
render: { releaseContinuousRender: noop },
};
const state = createState({ services });
const pending = [];
const renderers = [];
const parts = {
geometryQueue: { stopGeometryLoadQueue: noop },
cards: { teardownAmbientCards: noop, handleVisibilityChange: noop },
projection: { clearProjectionOverlay: noop, stopProjectionLoop: noop },
geometry: { destroyCoverageEntities: noop },
calibration: { loadCalibrationStore: () => new Map() },
catalog: {
loadCameraSources: () => new Promise((resolve) => pending.push(resolve)),
},
};
const rendererFactory = () => {
const renderer = {
owner: null,
released: false,
releases: 0,
hideCredit() {},
attach(owner) {
this.owner = owner;
},
createMarkers() {
if (failMarkers) throw new Error('marker allocation failed');
},
destroy() {
if (!this.released) {
this.released = true;
this.releases++;
}
},
};
renderers.push(renderer);
return renderer;
};
const layer = createLifecycle({
state,
services,
parts,
rendererFactory,
}).methods;
t.after(() => layer.destroy());
return { state, pending, parts, layer, renderers };
}
test('repeated initialization rejects without replacing the current CCTV owner', async (t) => {
const h = setup(t);
const owner = {};
const starting = h.layer.init(owner);
const stopped = assert.rejects(starting, { name: 'AbortError' });
await assert.rejects(h.layer.init({}), /already initialized/);
assert.equal(h.renderers.length, 1);
assert.equal(h.parts.renderer.owner, owner);
assert.equal(h.parts.renderer.released, false);
h.layer.destroy({});
h.pending[0]([]);
await stopped;
assert.equal(h.renderers[0].releases, 1);
});
test('a retired init rejection cannot destroy the newer CCTV owner', async (t) => {
const h = setup(t);
const first = assert.rejects(h.layer.init({}), { name: 'AbortError' });
h.layer.destroy();
const owner = {};
const second = assert.rejects(h.layer.init(owner), { name: 'AbortError' });
const current = h.parts.renderer;
h.pending[0]([]);
await first;
assert.equal(h.parts.renderer, current);
assert.equal(current.owner, owner);
assert.equal(current.released, false);
assert.equal(h.state._sourceAbort.signal.aborted, false);
h.layer.destroy();
h.pending[1]([]);
await second;
assert.deepEqual(
h.renderers.map((renderer) => renderer.releases),
[1, 1],
);
});
test('initial resource failure disposes its owner and leaves initialization retryable', async (t) => {
const h = setup(t, { failMarkers: true });
for (let i = 0; i < 2; i++) {
await assert.rejects(h.layer.init({}), /marker allocation failed/);
assert.equal(h.parts.renderer, null);
assert.equal(h.state._sourceAbort.signal.aborted, true);
}
assert.deepEqual(
h.renderers.map((renderer) => renderer.releases),
[1, 1],
);
});
test('destroy releases focus-appearance callbacks even without prior disable', (t) => {
const h = setup(t);
let released = 0;
h.state._removeFocusAppearListener = () => released++;
h.layer.destroy();
h.layer.destroy();
assert.equal(released, 1);
});
+7 -29
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { staticFrameRefreshMs } from '../../data/cctvLod.js'; import { staticFrameRefreshMs } from '../../data/cctvLod.js';
import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js'; import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js';
@@ -327,7 +326,7 @@ export function createModel({ state: layerState, services, parts, source }) {
*/ */
function currentViewContext() { function currentViewContext() {
const carto = layerState._viewer?.camera?.positionCartographic; const carto = parts.renderer?.cameraCartographic();
if (!carto) return 'none'; if (!carto) return 'none';
const lat = geo.math.toDegrees(carto.latitude); const lat = geo.math.toDegrees(carto.latitude);
const lon = geo.math.toDegrees(carto.longitude); const lon = geo.math.toDegrees(carto.longitude);
@@ -355,8 +354,7 @@ export function createModel({ state: layerState, services, parts, source }) {
* Reports whether the active Google Photorealistic 3D Tileset (if any) has * Reports whether the active Google Photorealistic 3D Tileset (if any) has
* finished loading the tiles in view. Shared mesh-floor sampling is gated on * finished loading the tiles in view. Shared mesh-floor sampling is gated on
* this so a one-shot cell never bakes in a miss from still-streaming tiles. * this so a one-shot cell never bakes in a miss from still-streaming tiles.
* Discovers + caches the tileset lazily from scene * The renderer discovers and caches its active tileset. When no
* primitives (the CCTV module holds only a `_viewer` reference). When no
* tileset is present (OSM fallback) this returns true so ground sampling is * tileset is present (OSM fallback) this returns true so ground sampling is
* not permanently blocked. * not permanently blocked.
* *
@@ -370,25 +368,7 @@ export function createModel({ state: layerState, services, parts, source }) {
*/ */
function projectionTilesReady() { function projectionTilesReady() {
if ( return parts.renderer?.tilesReady() ?? true;
!layerState._activeTileset ||
layerState._activeTileset.isDestroyed?.()
) {
layerState._activeTileset = null;
const primitives = layerState._viewer?.scene?.primitives;
if (primitives && typeof primitives.get === 'function') {
for (let i = 0; i < primitives.length; i++) {
const p = primitives.get(i);
if (p instanceof Cesium.Cesium3DTileset && !p.isDestroyed?.()) {
layerState._activeTileset = p;
break;
}
}
}
}
if (!layerState._activeTileset) return true;
if (layerState._activeTileset.show === false) return false;
return layerState._activeTileset.tilesLoaded === true;
} }
/** /**
@@ -399,8 +379,8 @@ export function createModel({ state: layerState, services, parts, source }) {
* frame-up, so the 16:9 texture maps upright and unmirrored. Static geometry — * frame-up, so the 16:9 texture maps upright and unmirrored. Static geometry —
* computed only on slider/save/activation, never per frame (§2b). * computed only on slider/save/activation, never per frame (§2b).
* @param {Object} camera - Camera pose. * @param {Object} camera - Camera pose.
* @param {Cesium.Cartesian3} capCenterPos - Plane center in ECEF. * @param {{x: number, y: number, z: number}} capCenterPos - Plane center in ECEF.
* @returns {Cesium.Quaternion} * @returns {{x: number, y: number, z: number, w: number}}
*/ */
function planeOrientationFor(camera, capCenterPos) { function planeOrientationFor(camera, capCenterPos) {
@@ -422,9 +402,7 @@ export function createModel({ state: layerState, services, parts, source }) {
m[6] = normal.x; m[6] = normal.x;
m[7] = normal.y; m[7] = normal.y;
m[8] = normal.z; m[8] = normal.z;
// The quaternion becomes an entity's ConstantProperty value, so it stays a return geo.quaternion.fromRotationMatrix(m, geo.quaternion.create());
// Cesium Quaternion there (that property clones and compares its value).
return geo.quaternion.fromRotationMatrix(m, new Cesium.Quaternion());
} }
/** /**
@@ -442,7 +420,7 @@ export function createModel({ state: layerState, services, parts, source }) {
*/ */
function projectionLoopIsNeeded() { function projectionLoopIsNeeded() {
if (!layerState._enabled || !layerState._viewer) return false; if (!layerState._enabled || !parts.renderer?.ready) return false;
if (layerState._showProjection && parts.selection.getActiveRecord()) if (layerState._showProjection && parts.selection.getActiveRecord())
return true; return true;
return focusPassIsNeeded( return focusPassIsNeeded(
+6 -23
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { CCTV_FOCUS_RESULT } from './policy.js'; import { CCTV_FOCUS_RESULT } from './policy.js';
@@ -14,7 +13,7 @@ export function createNavigation({
*/ */
function nearestCameraIdToViewer() { function nearestCameraIdToViewer() {
const carto = layerState._viewer?.camera?.positionCartographic; const carto = parts.renderer?.cameraCartographic();
if (!carto || !layerState._records.length) return null; if (!carto || !layerState._records.length) return null;
const lat = geo.math.toDegrees(carto.latitude); const lat = geo.math.toDegrees(carto.latitude);
const lon = geo.math.toDegrees(carto.longitude); const lon = geo.math.toDegrees(carto.longitude);
@@ -43,8 +42,8 @@ export function createNavigation({
* @returns {'focused'|'no-active-camera'|'tracking-holds-view'|'cockpit-active'} Focus result. * @returns {'focused'|'no-active-camera'|'tracking-holds-view'|'cockpit-active'} Focus result.
*/ */
function focusCctvRecord(viewer, record, duration = 2.2) { function focusCctvRecord(renderer, record, duration = 2.2) {
if (!viewer || !record) return CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA; if (!renderer || !record) return CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA;
if ( if (
typeof document !== 'undefined' && typeof document !== 'undefined' &&
document.body?.classList.contains('cockpit-mode') document.body?.classList.contains('cockpit-mode')
@@ -52,35 +51,19 @@ export function createNavigation({
console.debug('[Data:CCTV] focus ignored while cockpit owns the camera'); console.debug('[Data:CCTV] focus ignored while cockpit owns the camera');
return CCTV_FOCUS_RESULT.COCKPIT_ACTIVE; return CCTV_FOCUS_RESULT.COCKPIT_ACTIVE;
} }
if (viewer.trackedEntity) { if (renderer.following()) {
console.debug( console.debug(
'[Data:CCTV] focus ignored while a tracked entity owns the camera', '[Data:CCTV] focus ignored while a tracked entity owns the camera',
); );
return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW; return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW;
} }
const { camera } = record; renderer.focus(record, duration);
const range = Math.max(280, camera.rangeM * 1.18);
viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
record.position,
Math.max(40, camera.rangeM * 0.36),
),
{
offset: new Cesium.HeadingPitchRange(
parts.model.toRad(camera.headingDeg),
parts.model.toRad(-22),
range,
),
duration: Math.max(0.2, duration || 0),
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
},
);
return CCTV_FOCUS_RESULT.FOCUSED; return CCTV_FOCUS_RESULT.FOCUSED;
} }
function focusCamera(cameraId, duration = 2.2) { function focusCamera(cameraId, duration = 2.2) {
return focusCctvRecord( return focusCctvRecord(
layerState._viewer, parts.renderer?.ready ? parts.renderer : null,
layerState._recordById.get(cameraId), layerState._recordById.get(cameraId),
duration, duration,
); );
+21 -11
View File
@@ -5,7 +5,7 @@ export {
MEDIA_ENDPOINT, MEDIA_ENDPOINT,
ACTIVE_FRAME_REFRESH_MS, ACTIVE_FRAME_REFRESH_MS,
} from './sourcePolicy.js'; } from './sourcePolicy.js';
import * as Cesium from 'cesium'; import { rgbaFromCss } from '../shared/color.js';
import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js'; import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js';
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
@@ -404,38 +404,48 @@ export const CAMERA_SEEDS = [
// Visual style constants // Visual style constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// prettier-ignore
export const IDLE_CAMERA_COLOR = export const IDLE_CAMERA_COLOR =
Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.88); rgbaFromCss('#6be8ff').withAlpha(0.88);
// prettier-ignore
export const ACTIVE_CAMERA_COLOR = export const ACTIVE_CAMERA_COLOR =
Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95); rgbaFromCss('#ffd97a').withAlpha(0.95);
// prettier-ignore
export const IDLE_COVERAGE_COLOR = export const IDLE_COVERAGE_COLOR =
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.24); rgbaFromCss('#2fe0ff').withAlpha(0.24);
// prettier-ignore
export const IDLE_COVERAGE_CENTER_MUTED = export const IDLE_COVERAGE_CENTER_MUTED =
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.2); rgbaFromCss('#2fe0ff').withAlpha(0.2);
// prettier-ignore
export const IDLE_COVERAGE_EDGE_MUTED = export const IDLE_COVERAGE_EDGE_MUTED =
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.18); rgbaFromCss('#2fe0ff').withAlpha(0.18);
// prettier-ignore
export const ACTIVE_COVERAGE_EDGE = export const ACTIVE_COVERAGE_EDGE =
Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.58); rgbaFromCss('#8dff87').withAlpha(0.58);
// prettier-ignore
export const ACTIVE_COVERAGE_CENTER = export const ACTIVE_COVERAGE_CENTER =
Cesium.Color.fromCssColorString('#d7ff8d').withAlpha(0.82); rgbaFromCss('#d7ff8d').withAlpha(0.82);
// H6: dimmer depth-fail materials let the active frustum wireframe read // H6: dimmer depth-fail materials let the active frustum wireframe read
// through buildings while in monitor fallback mode. // through buildings while in monitor fallback mode.
// prettier-ignore
export const ACTIVE_COVERAGE_EDGE_DEPTHFAIL = export const ACTIVE_COVERAGE_EDGE_DEPTHFAIL =
Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.18); rgbaFromCss('#8dff87').withAlpha(0.18);
// prettier-ignore
export const ACTIVE_COVERAGE_CENTER_DEPTHFAIL = export const ACTIVE_COVERAGE_CENTER_DEPTHFAIL =
Cesium.Color.fromCssColorString('#d7ff8d').withAlpha(0.26); rgbaFromCss('#d7ff8d').withAlpha(0.26);
// prettier-ignore
export const PLANE_OUTLINE_COLOR = export const PLANE_OUTLINE_COLOR =
Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.55); rgbaFromCss('#6be8ff').withAlpha(0.55);
/** /**
* Card budget while the staggered geometry drain is running — the raised * Card budget while the staggered geometry drain is running — the raised
+46 -47
View File
@@ -1,10 +1,8 @@
import { attachCctvVideo } from './videoPlayback.js'; import { attachCctvVideo } from './videoPlayback.js';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { import {
CCTV_PROJECTION_OVERLAY_SOURCE_ID, CCTV_PROJECTION_OVERLAY_SOURCE_ID,
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS, CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
PLANE_OUTLINE_COLOR,
PROJECTION_CANVAS_WIDTH, PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT, PROJECTION_CANVAS_HEIGHT,
PROJECTION_TEXTURE_SWAP_MS, PROJECTION_TEXTURE_SWAP_MS,
@@ -72,17 +70,12 @@ export function createProjection({
); );
const positions = const positions =
record.frustumPositions || parts.geometry.frustumCartesians(geometry); record.frustumPositions || parts.geometry.frustumCartesians(geometry);
runtime.planeEntity.position = positions.capCenter; parts.renderer.updatePlane(
runtime.planeEntity.orientation = parts.model.planeOrientationFor( runtime,
record.camera, geometry,
positions.capCenter, positions,
parts.model.planeOrientationFor(record.camera, positions.capCenter),
); );
if (runtime.planeEntity.plane) {
runtime.planeEntity.plane.dimensions = new Cesium.Cartesian2(
geometry.halfW * 2,
geometry.halfH * 2,
);
}
if (runtime.labelPosition) { if (runtime.labelPosition) {
geo.cartesian3.clone(positions.label, runtime.labelPosition); geo.cartesian3.clone(positions.label, runtime.labelPosition);
} }
@@ -109,7 +102,8 @@ export function createProjection({
function setPlaneVisible(runtime, visible) { function setPlaneVisible(runtime, visible) {
if (!runtime) return; if (!runtime) return;
if (runtime.planeEntity) runtime.planeEntity.show = !!visible; runtime.planeVisible = !!visible;
parts.renderer.setPlaneVisible(runtime, !!visible);
if (visible && runtime.overlayEntry && runtime.cameraId) { if (visible && runtime.overlayEntry && runtime.cameraId) {
if (layerState._projectionOverlayOwnerId !== runtime.cameraId) { if (layerState._projectionOverlayOwnerId !== runtime.cameraId) {
layerState._cctvOverlayHost.setEntries( layerState._cctvOverlayHost.setEntries(
@@ -139,26 +133,14 @@ export function createProjection({
name: record.camera.name, name: record.camera.name,
position: () => runtime.labelPosition, position: () => runtime.labelPosition,
}); });
runtime.planeEntity = layerState._viewer.entities.add({ runtime.planeVisible = false;
id: `cctv-${record.camera.id}-plane`, runtime.planeEntity = parts.renderer.createPlane(
properties: { cctvCameraId: record.camera.id }, record,
show: false, runtime,
position: positions.capCenter, geometry,
orientation: parts.model.planeOrientationFor( positions,
record.camera, parts.model.planeOrientationFor(record.camera, positions.capCenter),
positions.capCenter, );
),
plane: {
plane: new Cesium.Plane(geo.cartesian3.UNIT_Z, 0.0),
dimensions: new Cesium.Cartesian2(
geometry.halfW * 2,
geometry.halfH * 2,
),
material: runtime.planeMaterial,
outline: true,
outlineColor: PLANE_OUTLINE_COLOR,
},
});
return runtime.planeEntity; return runtime.planeEntity;
} }
@@ -177,7 +159,7 @@ export function createProjection({
*/ */
function createProjectionRuntime(record) { function createProjectionRuntime(record) {
if (!layerState._viewer) return null; if (!parts.renderer?.ready) return null;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = PROJECTION_CANVAS_WIDTH; canvas.width = PROJECTION_CANVAS_WIDTH;
canvas.height = PROJECTION_CANVAS_HEIGHT; canvas.height = PROJECTION_CANVAS_HEIGHT;
@@ -219,6 +201,7 @@ export function createProjection({
}; };
parts.frames.paintProjectionPlaceholder(ctx, record.camera); parts.frames.paintProjectionPlaceholder(ctx, record.camera);
runtime.planeMaterial = parts.renderer.createPlaneMaterial(canvas);
if (mode === 'video') { if (mode === 'video') {
const video = document.createElement('video'); const video = document.createElement('video');
@@ -230,15 +213,25 @@ export function createProjection({
// attributes at first upload. Set them from the real stream dimensions // attributes at first upload. Set them from the real stream dimensions
// and rebind on any resolution change (camera switch, adaptive source). // and rebind on any resolution change (camera switch, adaptive source).
const bindVideoTexture = () => { const bindVideoTexture = () => {
if (runtime.video !== video || !runtime.planeMaterial) return; if (
runtime.disposed ||
runtime.video !== video ||
!runtime.planeMaterial
)
return;
if (!(video.videoWidth > 0 && video.videoHeight > 0)) return; if (!(video.videoWidth > 0 && video.videoHeight > 0)) return;
video.width = video.videoWidth; video.width = video.videoWidth;
video.height = video.videoHeight; video.height = video.videoHeight;
runtime.planeMaterial.image = runtime.canvas; parts.renderer.setPlaneImage(runtime, runtime.canvas);
runtime.planeMaterial.image = video; parts.renderer.setPlaneImage(runtime, video);
}; };
video.addEventListener('loadedmetadata', bindVideoTexture); video.addEventListener('loadedmetadata', bindVideoTexture);
video.addEventListener('resize', bindVideoTexture); video.addEventListener('resize', bindVideoTexture);
runtime.removeVideoBindings = () => {
video.removeEventListener('loadedmetadata', bindVideoTexture);
video.removeEventListener('resize', bindVideoTexture);
runtime.removeVideoBindings = null;
};
runtime.video = video; runtime.video = video;
runtime.playback = attachCctvVideo( runtime.playback = attachCctvVideo(
video, video,
@@ -247,20 +240,23 @@ export function createProjection({
{ {
onFailure: () => { onFailure: () => {
if (runtime.disposed) return; if (runtime.disposed) return;
runtime.removeVideoBindings?.();
runtime.video = null; runtime.video = null;
runtime.mode = 'image'; runtime.mode = 'image';
runtime.image = new Image(); runtime.image = new Image();
runtime.image.decoding = 'async'; runtime.image.decoding = 'async';
runtime.image.onload = () => { runtime.image.onload = () => {
if (runtime.disposed) return;
runtime.imageLoading = false; runtime.imageLoading = false;
runtime.imageReady = true; runtime.imageReady = true;
runtime.imageStamp = Date.now(); runtime.imageStamp = Date.now();
}; };
runtime.image.onerror = () => { runtime.image.onerror = () => {
if (runtime.disposed) return;
runtime.imageLoading = false; runtime.imageLoading = false;
runtime.imageReady = false; runtime.imageReady = false;
}; };
runtime.planeMaterial.image = runtime.canvas; parts.renderer.setPlaneImage(runtime, runtime.canvas);
parts.frames.refreshProjectionImage(record, true); parts.frames.refreshProjectionImage(record, true);
parts.presentation.notifyListeners(); parts.presentation.notifyListeners();
}, },
@@ -271,11 +267,13 @@ export function createProjection({
img.decoding = 'async'; img.decoding = 'async';
img.crossOrigin = 'anonymous'; img.crossOrigin = 'anonymous';
img.onload = () => { img.onload = () => {
if (runtime.disposed) return;
runtime.imageLoading = false; runtime.imageLoading = false;
runtime.imageReady = true; runtime.imageReady = true;
runtime.imageStamp = Date.now(); runtime.imageStamp = Date.now();
}; };
img.onerror = () => { img.onerror = () => {
if (runtime.disposed) return;
runtime.imageLoading = false; runtime.imageLoading = false;
runtime.imageReady = false; runtime.imageReady = false;
}; };
@@ -295,12 +293,12 @@ export function createProjection({
); );
const positions = const positions =
record.frustumPositions || parts.geometry.frustumCartesians(geometry); record.frustumPositions || parts.geometry.frustumCartesians(geometry);
runtime.planeMaterial = new Cesium.ImageMaterialProperty({ try {
image: canvas, createProjectionPlane(record, runtime, geometry, positions);
transparent: true, } catch (error) {
color: Cesium.Color.WHITE.withAlpha(0.95), destroyProjectionRuntime(runtime);
}); throw error;
createProjectionPlane(record, runtime, geometry, positions); }
return runtime; return runtime;
} }
@@ -332,6 +330,7 @@ export function createProjection({
function destroyProjectionRuntime(runtime) { function destroyProjectionRuntime(runtime) {
if (!runtime || runtime.disposed) return; if (!runtime || runtime.disposed) return;
runtime.disposed = true; runtime.disposed = true;
runtime.removeVideoBindings?.();
runtime.playback?.dispose(); runtime.playback?.dispose();
runtime.playback = null; runtime.playback = null;
if (runtime.image) { if (runtime.image) {
@@ -344,8 +343,8 @@ export function createProjection({
runtime.video.removeAttribute('src'); runtime.video.removeAttribute('src');
runtime.video.load(); runtime.video.load();
} }
if (runtime.planeEntity && layerState._viewer) { if (runtime.planeEntity) {
layerState._viewer.entities.remove(runtime.planeEntity); parts.renderer?.removePlane(runtime);
runtime.planeEntity = null; runtime.planeEntity = null;
} }
if (layerState._projectionOverlayOwnerId === runtime.cameraId) if (layerState._projectionOverlayOwnerId === runtime.cameraId)
@@ -366,7 +365,7 @@ export function createProjection({
if (revision !== loopRevision) return; if (revision !== loopRevision) return;
layerState._projectionRaf = 0; layerState._projectionRaf = 0;
layerState._projectionTimer = 0; layerState._projectionTimer = 0;
if (!layerState._viewer || !parts.model.projectionLoopIsNeeded()) { if (!parts.renderer?.ready || !parts.model.projectionLoopIsNeeded()) {
releaseContinuousRender('cctv-projection'); releaseContinuousRender('cctv-projection');
return; return;
} }
+187
View File
@@ -0,0 +1,187 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createProjection } from './projection.js';
function setup(
t,
{ sourceFails = false, planeFails = false, feedType = 'mp4' } = {},
) {
const previous = { document: globalThis.document, Image: globalThis.Image };
const videos = [];
const writes = [];
let removed = 0;
let refreshed = 0;
class Video extends EventTarget {
constructor() {
super();
this.listeners = new Map();
this.videoWidth = 320;
this.videoHeight = 180;
this.paused = true;
this.url = '';
videos.push(this);
}
addEventListener(type, callback) {
super.addEventListener(type, callback);
this.listeners.set(type, callback);
}
removeEventListener(type, callback) {
super.removeEventListener(type, callback);
if (this.listeners.get(type) === callback) this.listeners.delete(type);
}
set src(value) {
this.url = value;
if (sourceFails) this.dispatchEvent(new Event('error'));
}
removeAttribute(name) {
if (name === 'src') this.url = '';
}
play() {
this.paused = false;
return Promise.resolve();
}
pause() {
this.paused = true;
}
load() {}
}
globalThis.document = {
createElement: (tag) =>
tag === 'video' ? new Video() : { getContext: () => ({}) },
};
globalThis.Image = class {};
const state = {
_records: [],
_projectionEntities: [],
_projectionOverlayOwnerId: null,
_cctvOverlayHost: { clearSource() {}, setVisible() {} },
};
const renderer = {
ready: true,
createPlaneMaterial: (image) => ({ image }),
setPlaneImage(runtime, image) {
assert.ok(
runtime.planeMaterial,
'material must exist before playback failure',
);
runtime.planeMaterial.image = image;
writes.push(image);
},
createPlane() {
if (planeFails) throw new Error('plane construction failed');
return { show: false };
},
removePlane() {
removed++;
},
};
const record = {
camera: { id: 'camera', feedType },
frustumGeometry: { halfW: 16, halfH: 9 },
frustumPositions: {
capCenter: { x: 1, y: 2, z: 3 },
label: { x: 4, y: 5, z: 6 },
},
};
const projection = createProjection({
state,
services: { render: {} },
parts: {
renderer,
model: {
normalizeFeedType: (value) => value,
isVideoFeedType: (value) => value === 'mp4',
planeOrientationFor: () => ({}),
},
frames: {
paintProjectionPlaceholder() {},
mediaUrlFor: () => '/fixture.mp4',
refreshProjectionImage() {
refreshed++;
},
},
presentation: { notifyListeners() {} },
},
});
t.after(() => {
projection.destroyProjectionRuntime(record.projection);
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete globalThis[key];
else globalThis[key] = value;
}
});
return {
projection,
record,
videos,
writes,
removed: () => removed,
refreshed: () => refreshed,
};
}
test('video disposal removes texture and playback listeners and ignores retired metadata', (t) => {
const h = setup(t);
const runtime = h.projection.ensureProjectionRuntime(h.record);
const video = h.videos[0];
const retired = video.listeners.get('loadedmetadata');
video.dispatchEvent(new Event('loadedmetadata'));
assert.equal(video.width, 320);
assert.equal(video.height, 180);
assert.deepEqual(h.writes, [runtime.canvas, video]);
h.projection.destroyProjectionRuntime(runtime);
assert.equal(video.listeners.size, 0);
assert.equal(video.url, '');
assert.equal(video.paused, true);
retired();
assert.equal(h.writes.length, 2);
assert.equal(h.removed(), 1);
});
test('video failure releases its texture bindings while the image fallback remains usable', (t) => {
const h = setup(t);
const runtime = h.projection.ensureProjectionRuntime(h.record);
const video = h.videos[0];
const retired = video.listeners.get('resize');
video.dispatchEvent(new Event('error'));
assert.equal(runtime.mode, 'image');
assert.equal(runtime.video, null);
assert.equal(video.listeners.size, 0);
assert.equal(h.refreshed(), 1);
runtime.image.onload();
assert.equal(runtime.imageReady, true);
retired();
assert.equal(h.writes.length, 1);
});
test('a synchronous video source failure can bind the placeholder and recover as an image', (t) => {
const h = setup(t, { sourceFails: true });
const runtime = h.projection.ensureProjectionRuntime(h.record);
assert.equal(runtime.mode, 'image');
assert.ok(runtime.image);
assert.equal(runtime.planeMaterial.image, runtime.canvas);
assert.equal(h.videos[0].listeners.size, 0);
assert.equal(h.refreshed(), 1);
});
test('plane construction failure releases its already-attached media', (t) => {
const h = setup(t, { planeFails: true });
assert.throws(
() => h.projection.ensureProjectionRuntime(h.record),
/plane construction failed/,
);
assert.equal(h.videos[0].listeners.size, 0);
assert.equal(h.videos[0].url, '');
assert.equal(h.videos[0].paused, true);
assert.equal(h.record.projection, undefined);
});
test('retired image callbacks cannot change a disposed projection', (t) => {
const h = setup(t, { feedType: 'image' });
const runtime = h.projection.ensureProjectionRuntime(h.record);
const onload = runtime.image.onload;
h.projection.destroyProjectionRuntime(runtime);
onload();
assert.equal(runtime.imageReady, false);
assert.equal(runtime.imageStamp, 0);
});
@@ -54,6 +54,7 @@ function setup(t) {
}, },
}, },
parts: { parts: {
renderer: { ready: true },
model: { projectionLoopIsNeeded: () => state._enabled }, model: { projectionLoopIsNeeded: () => state._enabled },
selection: { getActiveRecord: () => record }, selection: { getActiveRecord: () => record },
rendering: { refreshCctvFocusStyles() {} }, rendering: { refreshCctvFocusStyles() {} },
+364
View File
@@ -0,0 +1,364 @@
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { horizonOccluder } from '../../../data/iconOrientation.js';
import { createFrustumVolumePrimitive } from './cesiumViewshed.js';
import {
bindTrackingClickGesture,
isTrackingClickGesture,
} from '../../../data/trackingClickGesture.js';
import { createCesiumCctvGizmoRenderer } from './cesiumGizmo.js';
import {
CAMERA_ICON,
IDLE_CAMERA_COLOR,
IDLE_COVERAGE_COLOR,
PLANE_OUTLINE_COLOR,
} from '../policy.js';
/** Native CCTV resources and scene operations owned by one viewer. */
export function createCctvCesiumRenderer({ services, state }) {
let viewer = null;
let billboards = null;
let activeTileset = null;
const entities = new Set();
const volumes = new Set();
const listeners = new Set();
const gizmos = new Set();
const color = (value) => (value ? Cesium.Color.clone(value) : undefined);
function ownListener(remove) {
const release = () => {
if (!listeners.delete(release)) return;
remove();
};
listeners.add(release);
return release;
}
function addEntity(options) {
const entity = viewer.entities.add(options);
entities.add(entity);
return entity;
}
function removeEntity(entity) {
if (!entities.delete(entity)) return;
if (viewer.entities.getById(entity.id) === entity)
viewer.entities.remove(entity);
}
function tilesReady() {
if (!activeTileset || activeTileset.isDestroyed?.()) {
activeTileset = null;
const primitives = viewer?.scene?.primitives;
if (primitives && typeof primitives.get === 'function') {
for (let i = 0; i < primitives.length; i++) {
const p = primitives.get(i);
if (p instanceof Cesium.Cesium3DTileset && !p.isDestroyed?.()) {
activeTileset = p;
break;
}
}
}
}
if (!activeTileset) return true;
return activeTileset.show !== false && activeTileset.tilesLoaded === true;
}
function extractPickedCameraId(picked) {
if (!picked) return null;
const entity = picked.id?.properties
? picked.id
: picked.primitive?.id?.properties
? picked.primitive.id
: null;
const maybeProp = entity?.properties?.cctvCameraId;
if (maybeProp) {
const value =
typeof maybeProp.getValue === 'function'
? maybeProp.getValue(Cesium.JulianDate.now())
: maybeProp;
const record =
typeof value === 'string' ? state._recordById.get(value) : null;
const ownsCoverageEntity = Boolean(
record?.coverageEntities?.includes(entity),
);
const ownsProjectionEntity =
record?.projection?.planeEntity === entity ||
state._projectionEntities.some(
(runtime) =>
runtime?.cameraId === value && runtime.planeEntity === entity,
);
if (record && (ownsCoverageEntity || ownsProjectionEntity)) return value;
}
// Camera billboard IDs are intentionally the upstream camera ID, so a
// sibling may legitimately use the same string. A bare ID match is not
// ownership proof: require this layer's billboard collection or the exact
// billboard stored on the record.
const directId =
typeof picked.id === 'string'
? picked.id
: typeof picked.primitive?.id === 'string'
? picked.primitive.id
: null;
const record = directId === null ? null : state._recordById.get(directId);
if (!record) return null;
return picked.primitive === billboards ||
picked.primitive === record.billboard
? directId
: null;
}
return {
extractPickedCameraId,
attach(owner) {
if (viewer && viewer !== owner)
throw new Error('CCTV renderer already has an owner');
viewer = owner;
activeTileset = null;
},
get ready() {
return Boolean(viewer && !viewer.isDestroyed?.());
},
cameraCartographic: () => viewer?.camera?.positionCartographic,
following: () => Boolean(viewer?.trackedEntity),
globeVisible: () => viewer?.scene?.globe?.show,
tilesReady,
project: (position, result) =>
viewer.scene.cartesianToCanvasCoordinates(position, result),
distance: (position) =>
geo.cartesian3.distance(viewer.camera.positionWC, position),
horizonTest() {
const occluder = horizonOccluder(viewer.camera);
return (position) => occluder.isPointVisible(position);
},
viewport() {
const canvas = viewer.scene.canvas;
return {
width: canvas.clientWidth || canvas.width || 0,
height: canvas.clientHeight || canvas.height || 0,
};
},
request: () => viewer?.scene?.requestRender?.(),
pick: (screen) => viewer.scene.pick(screen),
onMoveEnd: (fn) => ownListener(viewer.camera.moveEnd.addEventListener(fn)),
onMoveStart: (fn) =>
ownListener(viewer.camera.moveStart.addEventListener(fn)),
bindClick(onClick, options = {}) {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
bindTrackingClickGesture(
handler,
(event, gesture) => {
if (isTrackingClickGesture(gesture)) onClick(event);
},
options,
);
return ownListener(() => handler.destroy());
},
createGizmoRenderer() {
const gizmo = createCesiumCctvGizmoRenderer(viewer);
gizmos.add(gizmo);
return gizmo;
},
createMarkers() {
if (billboards) throw new Error('CCTV markers already exist');
billboards = new Cesium.BillboardCollection();
viewer.scene.primitives.add(billboards);
services.sprites.registerSpriteCollection('cctv', billboards);
return billboards;
},
createMarker(camera, position) {
return billboards.add({
id: camera.id,
image: CAMERA_ICON,
position,
color: color(IDLE_CAMERA_COLOR),
width: 24,
height: 24,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: new Cesium.NearFarScalar(350, 1.25, 4_000_000, 0.42),
});
},
setMarkersVisible(value) {
if (billboards) billboards.show = value;
},
restoreOrder: () => services.sprites.restoreSpriteOrder(viewer),
showCredit: () => services.credits?.showOsmCredit?.(viewer, 'cctv'),
hideCredit: () => services.credits?.hideOsmCredit?.(viewer, 'cctv'),
sampleMeshFloorCells: (points, options) =>
services.mesh.sampleMeshFloorCells(viewer?.scene, points, options),
obstructionRay(origin, direction, exclude, far) {
if (!viewer?.scene?.pickFromRay) return undefined;
const hit = viewer.scene.pickFromRay(
new Cesium.Ray(origin, direction),
[billboards, ...exclude].filter(Boolean),
);
return hit?.position &&
geo.cartesian3.distance(origin, hit.position) <= far
? hit
: undefined;
},
createPlaneMaterial(image) {
return new Cesium.ImageMaterialProperty({
image,
transparent: true,
color: Cesium.Color.WHITE.withAlpha(0.95),
});
},
createTestPlaneMaterial: () =>
new Cesium.ColorMaterialProperty(Cesium.Color.WHITE),
setPlaneImage(runtime, image) {
runtime.planeMaterial.image = image;
},
createPlane(record, runtime, geometry, positions, orientation) {
return addEntity({
id: `cctv-${record.camera.id}-plane`,
properties: { cctvCameraId: record.camera.id },
show: false,
position: positions.capCenter,
orientation: Cesium.Quaternion.clone(orientation),
plane: {
plane: new Cesium.Plane(Cesium.Cartesian3.UNIT_Z, 0.0),
dimensions: new Cesium.Cartesian2(
geometry.halfW * 2,
geometry.halfH * 2,
),
material: runtime.planeMaterial,
outline: true,
outlineColor: color(PLANE_OUTLINE_COLOR),
},
});
},
updatePlane(runtime, geometry, positions, orientation) {
const entity = runtime.planeEntity;
entity.position = positions.capCenter;
entity.orientation = Cesium.Quaternion.clone(orientation);
if (entity.plane)
entity.plane.dimensions = new Cesium.Cartesian2(
geometry.halfW * 2,
geometry.halfH * 2,
);
},
setPlaneVisible(runtime, visible) {
if (runtime.planeEntity) runtime.planeEntity.show = visible;
},
removePlane: (runtime) => removeEntity(runtime.planeEntity),
createCoverage(cameraId, positions) {
const created = [];
try {
for (const [role, points] of [
['ray-tl', [positions.mount, positions.tl]],
['ray-tr', [positions.mount, positions.tr]],
['ray-br', [positions.mount, positions.br]],
['ray-bl', [positions.mount, positions.bl]],
[
'cap',
[
positions.tl,
positions.tr,
positions.br,
positions.bl,
positions.tl,
],
],
]) {
const entity = addEntity({
id: `cctv-${cameraId}-${role}`,
properties: { cctvCameraId: cameraId },
polyline: {
positions: points,
width: 1.2,
material: color(IDLE_COVERAGE_COLOR),
},
});
entity._coverageRole = role === 'cap' ? 'cap' : 'edge';
created.push(entity);
}
return created;
} catch (error) {
for (const entity of created) removeEntity(entity);
throw error;
}
},
updateCoverage(record, positions) {
const lines = record.coverageEntities;
if (!lines || lines.length < 5) return;
for (const [i, role] of ['tl', 'tr', 'br', 'bl'].entries())
lines[i].polyline.positions = [positions.mount, positions[role]];
lines[4].polyline.positions = [
positions.tl,
positions.tr,
positions.br,
positions.bl,
positions.tl,
];
},
removeCoverageEntity: removeEntity,
styleLine(entity, { visible, width, tint, dashed, depthFail }) {
entity.show = visible;
if (!entity.polyline) return;
entity.polyline.width = width;
entity.polyline.material = dashed
? new Cesium.PolylineDashMaterialProperty({ color: color(tint) })
: color(tint);
entity.polyline.depthFailMaterial = color(depthFail);
},
hideCoverage(record) {
for (const entity of record.coverageEntities || []) entity.show = false;
},
removeCoverage(record) {
for (const entity of record.coverageEntities || []) removeEntity(entity);
},
createVolume(record, tint) {
const primitive = createFrustumVolumePrimitive(
record.frustumPositions,
color(tint),
);
primitive._gevViewshed = record.camera.id;
viewer.scene.primitives.add(primitive);
volumes.add(primitive);
return primitive;
},
removeVolume(primitive) {
if (volumes.delete(primitive)) viewer.scene.primitives.remove(primitive);
},
focus(record, duration) {
viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
record.position,
Math.max(40, record.camera.rangeM * 0.36),
),
{
offset: new Cesium.HeadingPitchRange(
geo.math.toRadians(record.camera.headingDeg),
geo.math.toRadians(-22),
Math.max(280, record.camera.rangeM * 1.18),
),
duration: Math.max(0.2, duration || 0),
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
},
);
},
destroy() {
if (!viewer) return;
services.credits?.hideOsmCredit?.(viewer, 'cctv');
for (const remove of listeners) remove();
for (const gizmo of gizmos) gizmo.destroy();
gizmos.clear();
for (const entity of entities) removeEntity(entity);
for (const volume of volumes) viewer.scene.primitives.remove(volume);
volumes.clear();
if (billboards) {
services.sprites.unregisterSpriteCollection?.('cctv', billboards);
viewer.scene.primitives.remove(billboards);
}
viewer = billboards = activeTileset = null;
},
};
}
export function bindCctvWorldClickGesture(handler, onClick, options = {}) {
bindTrackingClickGesture(
handler,
(click, gesture) => {
if (!isTrackingClickGesture(gesture)) return;
onClick(click);
},
options,
);
}
+158
View File
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import * as Cesium from 'cesium';
import { createCctvCesiumRenderer } from './cesium.js';
import { viewshedColors } from '../../../data/cctvViewshed.js';
function viewer() {
return {
entities: new Cesium.EntityCollection(),
scene: { primitives: new Cesium.PrimitiveCollection() },
camera: { moveStart: new Cesium.Event(), moveEnd: new Cesium.Event() },
isDestroyed: () => false,
};
}
function setup(t) {
const owner = viewer();
const sprites = [];
const state = { _recordById: new Map(), _projectionEntities: [] };
const renderer = createCctvCesiumRenderer({
state,
services: {
sprites: {
registerSpriteCollection: (...args) => sprites.push(['add', ...args]),
unregisterSpriteCollection: (...args) =>
sprites.push(['remove', ...args]),
},
},
});
renderer.attach(owner);
t.after(() => renderer.destroy());
const p = (x, y, z) => new Cesium.Cartesian3(x, y, z);
const positions = {
mount: p(6378137, 0, 10),
tl: p(6378137, 100, 40),
tr: p(6378137, 140, 40),
br: p(6378137, 140, 0),
bl: p(6378137, 100, 0),
capCenter: p(6378137, 120, 20),
};
return { owner, renderer, sprites, positions, state };
}
test('native CCTV releases only its original viewer resources and listeners', (t) => {
const h = setup(t);
const foreignViewer = viewer();
const foreign = foreignViewer.entities.add({ id: 'cctv-a-cap' });
const neighbor = h.owner.entities.add({ id: 'neighbor' });
const markers = h.renderer.createMarkers();
h.renderer.createMarker({ id: 'a' }, h.positions.mount);
h.renderer.createCoverage('a', h.positions);
const record = { camera: { id: 'a' }, frustumPositions: h.positions };
const volume = h.renderer.createVolume(record, viewshedColors(30).fill);
assert.equal(volume.allowPicking, false);
assert.equal(volume.asynchronous, false);
let moves = 0;
h.renderer.onMoveEnd(() => moves++);
h.owner.camera.moveEnd.raiseEvent();
assert.equal(moves, 1);
assert.throws(() => h.renderer.attach(foreignViewer), /already has an owner/);
h.renderer.destroy(foreignViewer);
h.renderer.destroy();
h.owner.camera.moveEnd.raiseEvent();
assert.equal(moves, 1);
assert.deepEqual(h.owner.entities.values, [neighbor]);
assert.equal(h.owner.scene.primitives.length, 0);
assert.equal(foreignViewer.entities.getById(foreign.id), foreign);
assert.deepEqual(h.sprites, [
['add', 'cctv', markers],
['remove', 'cctv', markers],
]);
});
test('coverage construction rolls back partial entities and can retry', (t) => {
const h = setup(t);
const collision = h.owner.entities.add({ id: 'cctv-a-ray-br' });
assert.throws(() => h.renderer.createCoverage('a', h.positions));
assert.deepEqual(h.owner.entities.values, [collision]);
h.owner.entities.remove(collision);
const lines = h.renderer.createCoverage('a', h.positions);
assert.equal(lines.length, 5);
assert.deepEqual(
lines.map((line) => line._coverageRole),
['edge', 'edge', 'edge', 'edge', 'cap'],
);
});
test('replaced native entity identities survive CCTV teardown', (t) => {
const h = setup(t);
const [line] = h.renderer.createCoverage('a', h.positions);
h.owner.entities.remove(line);
const replacement = h.owner.entities.add({ id: line.id });
h.renderer.destroy();
assert.deepEqual(h.owner.entities.values, [replacement]);
});
test('native materials convert portable colours and keep depth-fail and dash policy', (t) => {
const h = setup(t);
const [line] = h.renderer.createCoverage('a', h.positions);
const tint = viewshedColors(210).line;
h.renderer.styleLine(line, {
visible: true,
width: 1.8,
tint,
dashed: true,
depthFail: tint.withAlpha(0.18),
});
const now = Cesium.JulianDate.now();
assert.equal(line.polyline.material.getType(now), 'PolylineDash');
assert.ok(
Cesium.Color.equals(line.polyline.material.getValue(now).color, tint),
);
assert.equal(line.polyline.depthFailMaterial.getValue(now).color.alpha, 0.18);
h.renderer.styleLine(line, {
visible: false,
width: 0.9,
tint,
dashed: false,
});
assert.equal(line.show, false);
assert.equal(line.polyline.material.getType(now), 'Color');
assert.equal(line.polyline.depthFailMaterial, undefined);
});
test('obstruction queries include the owned marker set and enforce finite range', (t) => {
const h = setup(t);
const markers = h.renderer.createMarkers();
const exclude = [{}];
let query;
h.owner.scene.pickFromRay = (ray, exclusions) => {
query = { ray, exclusions };
return {
position: Cesium.Cartesian3.add(
ray.origin,
new Cesium.Cartesian3(0, 20, 0),
new Cesium.Cartesian3(),
),
};
};
assert.equal(
h.renderer.obstructionRay(
h.positions.mount,
Cesium.Cartesian3.UNIT_Y,
exclude,
10,
),
undefined,
);
assert.ok(
h.renderer.obstructionRay(
h.positions.mount,
Cesium.Cartesian3.UNIT_Y,
exclude,
30,
),
);
assert.deepEqual(query.exclusions, [markers, ...exclude]);
assert.ok(query.ray instanceof Cesium.Ray);
});
+45
View File
@@ -0,0 +1,45 @@
import * as Cesium from 'cesium';
import { frustumVolumeGeometryData } from '../../../data/cctvViewshed.js';
/**
* Builds the translucent frustum-volume primitive for one camera.
* Synchronous (6 triangles — cheaper than the async geometry pipeline's
* bookkeeping), unlit flat color, both faces visible (the viewer is routinely
* inside or behind a cone), never pickable (clicks fall through to the
* billboard/wireframe/plane, whose pick semantics are established).
* @param {Object} positions - frustumCartesians shape (see frustumVolumeGeometryData).
* @param {Cesium.Color} color - Per-camera fill color (already alpha'd).
* @returns {Cesium.Primitive}
*/
export function createFrustumVolumePrimitive(positions, color) {
const { positions: flat, indices } = frustumVolumeGeometryData(positions);
const geometry = new Cesium.Geometry({
attributes: {
position: new Cesium.GeometryAttribute({
componentDatatype: Cesium.ComponentDatatype.DOUBLE,
componentsPerAttribute: 3,
values: flat,
}),
},
indices,
primitiveType: Cesium.PrimitiveType.TRIANGLES,
boundingSphere: Cesium.BoundingSphere.fromVertices(Array.from(flat)),
});
return new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
geometry,
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(color),
},
}),
appearance: new Cesium.PerInstanceColorAppearance({
flat: true,
translucent: true,
renderState: {
cull: { enabled: false },
},
}),
asynchronous: false,
allowPicking: false,
});
}
+43 -61
View File
@@ -1,5 +1,3 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { isHeadingEstimated } from './headingConfidence.js'; import { isHeadingEstimated } from './headingConfidence.js';
import { import {
ACTIVE_CAMERA_COLOR, ACTIVE_CAMERA_COLOR,
@@ -37,27 +35,26 @@ export function createRendering({
const target = getFocusTarget(); const target = getFocusTarget();
if ( if (
!layerState._enabled || !layerState._enabled ||
!layerState._viewer || !parts.renderer?.ready ||
!focusPassIsNeeded(target, layerState._activeFocusStyleCount) !focusPassIsNeeded(target, layerState._activeFocusStyleCount)
) )
return; return;
if (nowMs - layerState._lastFocusStyleAt < 80) return; if (nowMs - layerState._lastFocusStyleAt < 80) return;
layerState._lastFocusStyleAt = nowMs; layerState._lastFocusStyleAt = nowMs;
const scene = layerState._viewer.scene;
const camera = layerState._viewer.camera;
const result = applyCctvFocusDeemphasis({ const result = applyCctvFocusDeemphasis({
records: layerState._records, records: layerState._records,
target, target,
previousActiveCount: layerState._activeFocusStyleCount, previousActiveCount: layerState._activeFocusStyleCount,
nowMs, nowMs,
// prettier-ignore
screenPositionFor: (position) => screenPositionFor: (position) =>
Cesium.SceneTransforms.worldToWindowCoordinates( parts.renderer.project(
scene,
position, position,
layerState._scratchFocusScreen, layerState._scratchFocusScreen,
), ),
// prettier-ignore
cameraDistanceFor: (position) => cameraDistanceFor: (position) =>
Cesium.Cartesian3.distance(camera.positionWC, position), parts.renderer.distance(position),
baseColorFor: (record) => baseColorFor: (record) =>
record.camera.id === layerState._activeCameraId record.camera.id === layerState._activeCameraId
? ACTIVE_CAMERA_COLOR ? ACTIVE_CAMERA_COLOR
@@ -136,17 +133,17 @@ export function createRendering({
*/ */
function refreshHorizonCulling() { function refreshHorizonCulling() {
// prettier-ignore
if ( if (
!layerState._viewer || !parts.renderer?.ready ||
layerState._viewer.isDestroyed() ||
!layerState._records.length !layerState._records.length
) )
return; return;
const occluder = horizonOccluder(layerState._viewer.camera); const horizonVisible = parts.renderer.horizonTest();
for (const record of layerState._records) { for (const record of layerState._records) {
const bb = record.billboard; const bb = record.billboard;
if (!bb) continue; if (!bb) continue;
const visible = occluder.isPointVisible(bb.position); const visible = horizonVisible(bb.position);
if (bb.show !== visible) bb.show = visible; if (bb.show !== visible) bb.show = visible;
} }
} }
@@ -173,8 +170,10 @@ export function createRendering({
} }
for (const entity of record?.coverageEntities || []) entity.show = false; for (const entity of record?.coverageEntities || []) entity.show = false;
if (record?.viewshedPrimitive) destroyVolume?.(record); if (record?.viewshedPrimitive) destroyVolume?.(record);
if (record?.projection?.planeEntity) if (record?.projection?.planeEntity) {
record.projection.planeEntity.show = false; record.projection.planeEntity.show = false;
record.projection.planeVisible = false;
}
} }
} }
@@ -185,7 +184,7 @@ export function createRendering({
layerState._activeCameraId, layerState._activeCameraId,
); );
parts.projection.clearProjectionOverlay(); parts.projection.clearProjectionOverlay();
if (layerState._billboards) layerState._billboards.show = false; parts.renderer?.setMarkersVisible(false);
parts.projection.pauseInactiveProjectionFeeds(null); parts.projection.pauseInactiveProjectionFeeds(null);
} }
@@ -239,56 +238,40 @@ export function createRendering({
// rather than rendering identically to a surveyed one (#639). Colors, // rather than rendering identically to a surveyed one (#639). Colors,
// widths, and the active/idle emphasis are unchanged. // widths, and the active/idle emphasis are unchanged.
const bearingEstimated = isHeadingEstimated(record.camera); const bearingEstimated = isHeadingEstimated(record.camera);
const lineMaterial = (color) =>
bearingEstimated
? new Cesium.PolylineDashMaterialProperty({ color })
: color;
for (const entity of record.coverageEntities || []) { for (const entity of record.coverageEntities || []) {
// The frustum wireframe is part of the projection representation — const visible = !!(
// force it on for the active camera and let it read through geometry
// via depthFailMaterial (polylines have no disableDepthTestDistance).
entity.show = !!(
layerState._enabled && layerState._enabled &&
((coverageOn && inVisibleSet) || planeShowing) ((coverageOn && inVisibleSet) || planeShowing)
); );
if (!entity.polyline) continue;
// Viewshed mode swaps the cyan/green scheme for the camera's own hue so
// adjacent cones read as distinct coverage claims (design §3b); the
// active camera keeps its width/alpha emphasis in both schemes.
const hue = viewshedOn ? record.viewshedColors : null; const hue = viewshedOn ? record.viewshedColors : null;
if (entity._coverageRole === 'cap') { const cap = entity._coverageRole === 'cap';
entity.polyline.material = lineMaterial( const tint = hue
hue ? isActive
? isActive ? hue.lineActive
? hue.lineActive : cap
: hue.line ? hue.line
: isActive : hue.line.withAlpha(0.6)
? ACTIVE_COVERAGE_CENTER : cap
: IDLE_COVERAGE_CENTER_MUTED, ? isActive
); ? ACTIVE_COVERAGE_CENTER
entity.polyline.width = isActive ? 2.2 : 1.0; : IDLE_COVERAGE_CENTER_MUTED
entity.polyline.depthFailMaterial = planeShowing : isActive
? hue ? ACTIVE_COVERAGE_EDGE
? hue.line.withAlpha(0.26) : IDLE_COVERAGE_EDGE_MUTED;
: ACTIVE_COVERAGE_CENTER_DEPTHFAIL const depthFail = !planeShowing
: undefined; ? undefined
} else { : hue
entity.polyline.material = lineMaterial( ? hue.line.withAlpha(cap ? 0.26 : 0.18)
hue : cap
? isActive ? ACTIVE_COVERAGE_CENTER_DEPTHFAIL
? hue.lineActive : ACTIVE_COVERAGE_EDGE_DEPTHFAIL;
: hue.line.withAlpha(0.6) parts.renderer.styleLine(entity, {
: isActive visible,
? ACTIVE_COVERAGE_EDGE width: cap ? (isActive ? 2.2 : 1.0) : isActive ? 1.8 : 0.9,
: IDLE_COVERAGE_EDGE_MUTED, tint,
); dashed: bearingEstimated,
entity.polyline.width = isActive ? 1.8 : 0.9; depthFail,
entity.polyline.depthFailMaterial = planeShowing });
? hue
? hue.line.withAlpha(0.18)
: ACTIVE_COVERAGE_EDGE_DEPTHFAIL
: undefined;
}
} }
// Viewshed volume lifecycle: exists iff enabled + viewshed mode + in the // Viewshed volume lifecycle: exists iff enabled + viewshed mode + in the
@@ -316,8 +299,7 @@ export function createRendering({
} }
} }
if (layerState._billboards) parts.renderer?.setMarkersVisible(!!layerState._enabled);
layerState._billboards.show = !!layerState._enabled;
parts.projection.pauseInactiveProjectionFeeds(activeId); parts.projection.pauseInactiveProjectionFeeds(activeId);
} }
return { return {
+1 -72
View File
@@ -1,9 +1,3 @@
import {
bindTrackingClickGesture,
isTrackingClickGesture,
} from '../../data/trackingClickGesture.js';
import * as Cesium from 'cesium';
export function createSelection({ export function createSelection({
state: layerState, state: layerState,
services, services,
@@ -33,27 +27,6 @@ export function createSelection({
return fallback; return fallback;
} }
/**
* Bind CCTV activation to clean taps while preserving the layer's hover-move
* callback on the shared Cesium handler. Drag-like and long-press gestures do
* not reach camera activation or focus dispatch.
* @param {Cesium.ScreenSpaceEventHandler|Object} handler - Input handler.
* @param {(click: Object) => void} onClick - Accepted CCTV click callback.
* @param {Object} [options] - Gesture test seams and optional onMouseMove hook.
* @returns {void}
*/
function bindCctvWorldClickGesture(handler, onClick, options = {}) {
bindTrackingClickGesture(
handler,
(click, gesture) => {
if (!isTrackingClickGesture(gesture)) return;
onClick(click);
},
options,
);
}
/** /**
* Sets the active camera by ID, initializes its projection runtime, refreshes * Sets the active camera by ID, initializes its projection runtime, refreshes
* its frame, and updates styles. * its frame, and updates styles.
@@ -206,54 +179,10 @@ export function createSelection({
*/ */
function extractPickedCameraId(picked) { function extractPickedCameraId(picked) {
if (!picked) return null; return parts.renderer?.extractPickedCameraId(picked) ?? null;
const entity = picked.id?.properties
? picked.id
: picked.primitive?.id?.properties
? picked.primitive.id
: null;
const maybeProp = entity?.properties?.cctvCameraId;
if (maybeProp) {
const value =
typeof maybeProp.getValue === 'function'
? maybeProp.getValue(Cesium.JulianDate.now())
: maybeProp;
const record =
typeof value === 'string' ? layerState._recordById.get(value) : null;
const ownsCoverageEntity = Boolean(
record?.coverageEntities?.includes(entity),
);
const ownsProjectionEntity =
record?.projection?.planeEntity === entity ||
layerState._projectionEntities.some(
(runtime) =>
runtime?.cameraId === value && runtime.planeEntity === entity,
);
if (record && (ownsCoverageEntity || ownsProjectionEntity)) return value;
}
// Camera billboard IDs are intentionally the upstream camera ID, so a
// sibling may legitimately use the same string. A bare ID match is not
// ownership proof: require this layer's billboard collection or the exact
// billboard stored on the record.
const directId =
typeof picked.id === 'string'
? picked.id
: typeof picked.primitive?.id === 'string'
? picked.primitive.id
: null;
const record =
directId === null ? null : layerState._recordById.get(directId);
if (!record) return null;
return picked.primitive === layerState._billboards ||
picked.primitive === record.billboard
? directId
: null;
} }
return { return {
getActiveRecord, getActiveRecord,
bindCctvWorldClickGesture,
setActiveCamera, setActiveCamera,
deactivateActiveCamera, deactivateActiveCamera,
cctvEmptyClickDeselects, cctvEmptyClickDeselects,
+11 -2
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createCctvSource, createCctvLayer } from './index.js'; import { createCctvSource, createCctvLayer } from './index.js';
import { createCctvCesiumRenderer } from './render/cesium.js';
const camera = { const camera = {
id: 'pack/camera ?x', id: 'pack/camera ?x',
@@ -131,8 +132,16 @@ test('camera construction is inert and destruction cancels a pending catalog and
}); });
}, },
}; };
const a = createCctvLayer({ services, source }); const a = createCctvLayer({
const b = createCctvLayer({ services, source }); services,
source,
rendererFactory: createCctvCesiumRenderer,
});
const b = createCctvLayer({
services,
source,
rendererFactory: createCctvCesiumRenderer,
});
assert.equal(listeners.size, 0); assert.equal(listeners.size, 0);
const viewer = { const viewer = {
scene: { primitives: { add: (value) => value, remove: () => true } }, scene: { primitives: { add: (value) => value, remove: () => true } },
+1 -7
View File
@@ -13,10 +13,6 @@ export function createState({ services }) {
// Module-scoped mutable state // Module-scoped mutable state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
layerState._viewer = null;
layerState._billboards = null;
layerState._records = []; layerState._records = [];
layerState._recordById = new Map(); layerState._recordById = new Map();
@@ -48,7 +44,7 @@ export function createState({ services }) {
layerState._lastViewContext = ''; layerState._lastViewContext = '';
layerState._clickHandler = null; layerState._removeClick = null;
layerState._count = 0; layerState._count = 0;
@@ -113,8 +109,6 @@ export function createState({ services }) {
// tilesLoaded flag so a coarse-LOD miss is never baked in. Cleared when the // tilesLoaded flag so a coarse-LOD miss is never baked in. Cleared when the
// tileset is destroyed / the layer tears down. // tileset is destroyed / the layer tears down.
layerState._activeTileset = null;
// Task 5: last surface regime the record geometry was recomputed for. The // Task 5: last surface regime the record geometry was recomputed for. The
// map-stack change listener compares the CURRENT regime (derived live from // map-stack change listener compares the CURRENT regime (derived live from
// scene.globe.show) against this so bing→osm switches (same 'terrain-globe' // scene.globe.show) against this so bing→osm switches (same 'terrain-globe'
+26 -7
View File
@@ -1,6 +1,26 @@
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
export function createTesting({
state: layerState,
services,
parts,
source,
rendererFactory,
}) {
let testViewer;
function useRenderer(viewer) {
if (parts.renderer && testViewer === viewer) return;
parts.renderer?.destroy();
parts.renderer = rendererFactory({
state: layerState,
services,
parts,
source,
});
parts.renderer.attach(viewer);
testViewer = viewer;
}
export function createTesting({ state: layerState, services, parts, source }) {
/** Test seam: republishes host entries through the real push path, so tests /** Test seam: republishes host entries through the real push path, so tests
* can observe the pristine module default without touching the setter. */ * can observe the pristine module default without touching the setter. */
@@ -25,7 +45,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
*/ */
function _createCctvProjectionPlaneForTest(viewer, record) { function _createCctvProjectionPlaneForTest(viewer, record) {
layerState._viewer = viewer; useRenderer(viewer);
const geometry = const geometry =
record.frustumGeometry || record.frustumGeometry ||
parts.geometry.computeFrustumGeometry( parts.geometry.computeFrustumGeometry(
@@ -40,9 +60,9 @@ export function createTesting({ state: layerState, services, parts, source }) {
const runtime = { const runtime = {
cameraId: String(record.camera.id), cameraId: String(record.camera.id),
planeEntity: null, planeEntity: null,
labelPosition: new Cesium.Cartesian3(), labelPosition: geo.cartesian3.create(),
overlayEntry: null, overlayEntry: null,
planeMaterial: new Cesium.ColorMaterialProperty(Cesium.Color.WHITE), planeMaterial: parts.renderer.createTestPlaneMaterial(),
}; };
parts.projection.createProjectionPlane( parts.projection.createProjectionPlane(
record, record,
@@ -90,7 +110,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
coverageMode = 'on', coverageMode = 'on',
showProjection = false, showProjection = false,
} = {}) { } = {}) {
layerState._viewer = viewer; useRenderer(viewer);
layerState._records = Array.isArray(records) ? records : []; layerState._records = Array.isArray(records) ? records : [];
layerState._recordById = new Map( layerState._recordById = new Map(
layerState._records layerState._records
@@ -99,7 +119,6 @@ export function createTesting({ state: layerState, services, parts, source }) {
); );
layerState._coverageEntities = []; layerState._coverageEntities = [];
layerState._projectionEntities = []; layerState._projectionEntities = [];
layerState._billboards = null;
layerState._activeCameraId = activeCameraId; layerState._activeCameraId = activeCameraId;
layerState._autoHopSuspended = false; layerState._autoHopSuspended = false;
layerState._enabled = !!enabled; layerState._enabled = !!enabled;