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/gizmo.test.mjs",
"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/rendering.js",
"src/layers/cctv/testing.js",
+6 -8
View File
@@ -1449,10 +1449,6 @@
"src/layers/shared/color.js",
"src/data/cctvLod.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/cards.js",
"src/layers/cctv/catalog.js",
@@ -1497,11 +1493,9 @@
"src/geo/scaleToGeodeticSurface.js",
"src/geo/check.js",
"src/geo/debug.js",
"src/geo/developerError.js",
"src/globe/contract.js",
"src/globe/cesium/engineFromViewer.js"
"src/geo/developerError.js"
],
"external": ["hls.js", "cesium"]
"external": ["hls.js"]
},
"street-level-layer": {
"exports": [
@@ -1835,6 +1829,8 @@
"src/app/layers/bikeshare.js",
"src/app/layers/cctv.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/firms.js",
"src/app/layers/flights.js",
@@ -2793,6 +2789,8 @@
"src/app/layers/bikeshare.js",
"src/app/layers/cctv.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/firms.js",
"src/app/layers/flights.js",
+77 -3
View File
@@ -68,6 +68,8 @@
* 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]
* 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.
*/
@@ -99,6 +101,9 @@ const output = getOpt('--out');
if (!explicitUrl || !output)
throw new Error('Explicit --url and --out are required');
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);
if (
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
@@ -404,6 +409,17 @@ async function main() {
try {
const page = await browser.newPage();
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 });
watchW1Page(page, evidence, {
name: 'cctv-legacy',
@@ -435,6 +451,27 @@ async function main() {
if (layer.enabled) await app.dataManager.setEnabled(id, false);
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.
await sleep(4000);
@@ -619,7 +656,7 @@ async function main() {
console.log('Waiting for tiles + the one-shot completion pass...');
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
let sampleFloorOk = false;
if (tilesSeenLoaded) {
if (tilesSeenLoaded && !terrainGlobe) {
sampleFloorOk = await page
.waitForFunction(
(base) => window.__qaCounters.sampleHeight - base >= 1,
@@ -631,8 +668,12 @@ async function main() {
}
const cFloor = await readCounters();
record(
'>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
tilesSeenLoaded && sampleFloorOk,
terrainGlobe
? '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
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
: 'tilesLoaded never observed within 45s — floor not assessable this run',
@@ -1588,6 +1629,24 @@ async function main() {
});
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 mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
mod.setParams({ coverageMode: 'viewshed' });
@@ -1638,6 +1697,18 @@ async function main() {
mod.setParams({ coverageMode: 'viewshed' });
});
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(() => {
const prims = window.__godsEyeView.viewer.scene.primitives;
window.__qaViewshedRefs = [];
@@ -1659,6 +1730,9 @@ async function main() {
return { sameCount, sameIdentity, count: now.length };
});
const cAfterViewshed = await readCounters();
evidence.viewshedTrace = await page.evaluate(
() => window.__qaViewshedTrace,
);
const lateFloorSamples =
cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight;
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 * as credits from '../../data/dataCredits.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';
/** 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;
return createCctvLayer({
const layer = createCctvLayer({
source,
rendererFactory,
services: {
createGizmoRenderer: createCesiumCctvGizmoRenderer,
credits,
sprites,
activation,
@@ -29,4 +36,16 @@ export function createApplicationCctv({ surface, source }) {
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);
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);
@@ -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.match(
componentFunctionSource(cctvLayer.init),
/bindCctvWorldClickGesture\(_clickHandler/,
/_removeClick = bindClick\(/,
);
assert.match(
componentFunctionSource(cctvLayer.init),
+35 -55
View File
@@ -6,17 +6,17 @@
*
* Two responsibilities, both pure of layer state:
* - Color identity: a stable per-camera hue (golden-angle spaced over the
* id-sorted catalog index) and the derived fill/line Cesium colors.
* - Volume geometry: a translucent frustum pyramid primitive built FROM the
* id-sorted catalog index) and the derived fill/line RGBA colors.
* - Volume geometry: frustum pyramid buffers built FROM the
* exact frustumCartesians positions the wireframe uses — welded by
* construction, never recomputed independently. 5 vertices, 6 triangles,
* synchronous build (trivially small), no picking.
* construction, never recomputed independently. 5 vertices, 6 triangles.
* The renderer owns primitive creation and picking policy.
*
* Nothing here queries the scene or persists anything: colors are
* presentation-only and volumes are rebuilt only at the moments the frustum
* 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. */
const GOLDEN_ANGLE_DEG = 137.50776405003785;
@@ -39,19 +39,42 @@ export function cameraHue(index) {
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)
* and wireframe line tints (idle/active), all in the same hue family.
* @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) {
const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360;
return {
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
line: Cesium.Color.fromHsl(hue, 0.9, 0.65, LINE_ALPHA_IDLE),
lineActive: Cesium.Color.fromHsl(hue, 0.9, 0.7, LINE_ALPHA_ACTIVE),
fill: viewshedHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
fillActive: viewshedHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
line: viewshedHsl(hue, 0.9, 0.65, LINE_ALPHA_IDLE),
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
* geometry definition (weld-by-construction with the wireframe, which draws
* its rays/cap from the same 5 Cartesians).
* @param {{mount: Cesium.Cartesian3, tl: Cesium.Cartesian3, tr: Cesium.Cartesian3,
* br: Cesium.Cartesian3, bl: Cesium.Cartesian3}} positions - frustumCartesians shape.
* @param {{mount: Object, tl: Object, tr: Object,
* br: Object, bl: Object}} positions - ECEF frustumCartesians shape.
* @returns {{positions: Float64Array, indices: Uint16Array}}
*/
export function frustumVolumeGeometryData(positions) {
@@ -85,46 +108,3 @@ export function frustumVolumeGeometryData(positions) {
]);
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', () => {
const colors = viewshedColors(210);
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.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);
});
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. */
function positionsFixture() {
return {
+6 -2
View File
@@ -9,6 +9,7 @@ import * as annotations from '../annotations/worldAnnotationRenderer.js';
import { createDirectionsLayer } from '../layers/directions/index.js';
import { createDirectionsSource } from '../layers/directions/source.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 { 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) => {
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 },
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();
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
});
+2 -2
View File
@@ -297,7 +297,7 @@ export function createCalibration({
*/
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
// 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.
@@ -306,7 +306,7 @@ export function createCalibration({
? record
: null;
layerState._gizmo = createCalibrationGizmo({
renderer: services.createGizmoRenderer(layerState._viewer),
renderer: parts.renderer.createGizmoRenderer(),
getActiveRecord: () =>
layerState._enabled && layerState._calibrationMode
? parts.selection.getActiveRecord()
+8 -12
View File
@@ -9,7 +9,6 @@ import {
applyFrameResult,
} from '../../data/cctvCards.js';
import * as geo from '../../geo/index.js';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { selectCctvLod, applyEvictionGrace } from '../../data/cctvLod.js';
import {
CCTV_OVERLAY_SOURCE_OPTIONS,
@@ -35,7 +34,7 @@ export function createCards({ state: layerState, services, parts, source }) {
} = {}) {
layerState._activeCameraCardEnabled = activeCameraCardEnabled === true;
if (layerState._enabled) pushAmbientCardEntries();
layerState._viewer?.scene?.requestRender?.();
parts.renderer?.request();
return { activeCameraCardEnabled: layerState._activeCameraCardEnabled };
}
@@ -72,8 +71,7 @@ export function createCards({ state: layerState, services, parts, source }) {
function refreshAmbientCards() {
if (
!layerState._enabled ||
!layerState._viewer ||
layerState._viewer.isDestroyed() ||
!parts.renderer?.ready ||
!layerState._records.length
) {
layerState._cctvOverlayHost.setEntries(
@@ -83,17 +81,15 @@ export function createCards({ state: layerState, services, parts, source }) {
);
return;
}
const scene = layerState._viewer.scene;
const carto = layerState._viewer.camera.positionCartographic;
const carto = parts.renderer.cameraCartographic();
const viewerLat = carto ? geo.math.toDegrees(carto.latitude) : 0;
const viewerLon = carto ? geo.math.toDegrees(carto.longitude) : 0;
// The active camera is excluded from ambient selection ALWAYS (not just
// after its async activation settles). It is published separately in the
// protected lane below, and grace never applies to it.
const activeId = layerState._activeCameraId;
const occluder = horizonOccluder(layerState._viewer.camera);
const width = scene.canvas.clientWidth || scene.canvas.width || 0;
const height = scene.canvas.clientHeight || scene.canvas.height || 0;
const horizonVisible = parts.renderer.horizonTest();
const { width, height } = parts.renderer.viewport();
const marginX = width * 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 sx = NaN;
let sy = NaN;
if (occluder.isPointVisible(record.position)) {
const screen = scene.cartesianToCanvasCoordinates(record.position);
if (horizonVisible(record.position)) {
const screen = parts.renderer.project(record.position);
if (
screen &&
Number.isFinite(screen.x) &&
@@ -325,7 +321,7 @@ export function createCards({ state: layerState, services, parts, source }) {
slot,
applyFrameResult(slot, { ok: !!frame, frame }, Date.now()),
);
layerState._viewer?.scene?.requestRender?.();
parts.renderer?.request();
};
image.onload = () => settle(true);
image.onerror = () => settle(false);
+2 -2
View File
@@ -127,7 +127,7 @@ export function createFrames({ state: layerState, services, parts, source }) {
)
return;
const planeShowing = !!(runtime.planeEntity?.show && runtime.planeMaterial);
const planeShowing = !!(runtime.planeVisible && runtime.planeMaterial);
if (!planeShowing) return;
// 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;
runtime.lastTextureSwapAt = now;
runtime.lastSwappedCanvasStamp = runtime.canvasStamp;
runtime.planeMaterial.image = buffer;
parts.renderer.setPlaneImage(runtime, buffer);
return true;
}
+21 -80
View File
@@ -1,6 +1,4 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js';
import {
PROJECTION_VERT_ASPECT,
FRUSTUM_GROUND_CLEARANCE_M,
@@ -8,7 +6,6 @@ import {
PROBE_CLEARANCE_M,
COVERAGE_NEIGHBOR_RADIUS_KM,
COVERAGE_NEIGHBOR_LIMIT,
IDLE_COVERAGE_COLOR,
PLANE_FOOTPRINT_LIFT_CAP_M,
} from './policy.js';
@@ -20,7 +17,6 @@ import {
export function createGeometry({ state: layerState, services, parts, source }) {
const { warmGroundFloor, cachedGroundFloor } = services.ground;
const { sampleMeshFloorCells } = services.mesh;
/**
* 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) {
record.billboard.position = positions.mount;
}
if (record.coverageEntities?.length >= 5) {
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,
];
}
parts.renderer?.updateCoverage(record, positions);
// A live viewshed volume tracks its wireframe: rebuild from the SAME fresh
// positions (weld invariant). Only records currently showing a volume pay
// 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()) {
record.groundMeshSampleRequestCount =
(record.groundMeshSampleRequestCount || 0) + 1;
const viewerCarto = layerState._viewer?.camera?.positionCartographic;
const viewerCarto = parts.renderer?.cameraCartographic();
const excludeObjects = [...(record.coverageEntities || [])];
if (record.billboard) excludeObjects.push(record.billboard);
if (record.projection?.planeEntity)
excludeObjects.push(record.projection.planeEntity);
sampleMeshFloorCells(layerState._viewer?.scene, [point], {
parts.renderer.sampleMeshFloorCells([point], {
excludeObjects: excludeObjects.filter(Boolean),
viewerLat: viewerCarto
? geo.math.toDegrees(viewerCarto.latitude)
@@ -530,9 +502,8 @@ export function createGeometry({ state: layerState, services, parts, source }) {
function destroyViewshedVolume(record) {
if (!record) return;
if (record.viewshedPrimitive && layerState._viewer) {
layerState._viewer.scene.primitives.remove(record.viewshedPrimitive);
}
if (record.viewshedPrimitive)
parts.renderer?.removeVolume(record.viewshedPrimitive);
record.viewshedPrimitive = null;
}
@@ -550,7 +521,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
function rebuildViewshedVolume(record, isActive) {
destroyViewshedVolume(record);
if (
!layerState._viewer ||
!parts.renderer?.ready ||
!record?.frustumPositions ||
!record.viewshedColors
)
@@ -558,14 +529,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
const color = isActive
? record.viewshedColors.fillActive
: record.viewshedColors.fill;
const primitive = createFrustumVolumePrimitive(
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.viewshedPrimitive = parts.renderer.createVolume(record, color);
record.viewshedActiveTint = !!isActive;
}
@@ -611,8 +575,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
function runActivationObstructionProbe(record) {
record.probeClampRangeM = null;
const scene = layerState._viewer?.scene;
if (!scene || typeof scene.pickFromRay !== 'function') return;
if (!parts.renderer?.ready) return;
const camera = record.camera;
const rangeScale = parts.calibration.normalizeCalibration(
camera.calibration,
@@ -628,11 +591,19 @@ export function createGeometry({ state: layerState, services, parts, source }) {
const { dir } = frustumFrameEcef(camera, mountPos);
// Exclude everything the layer itself draws so the probe can only hit the
// 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) {
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;
const dist = geo.cartesian3.distance(mountPos, hit.position);
record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist);
@@ -684,37 +655,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
record.position = positions.mount;
}
const addPolyline = (role, linePositions) =>
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;
return parts.renderer.createCoverage(camera.id, positions);
}
/**
@@ -797,9 +738,9 @@ export function createGeometry({ state: layerState, services, parts, source }) {
*/
function destroyCoverageEntities() {
if (!layerState._viewer) return;
if (!parts.renderer?.ready) return;
for (const entity of layerState._coverageEntities) {
layerState._viewer.entities.remove(entity);
parts.renderer.removeCoverageEntity(entity);
}
layerState._coverageEntities = [];
+3 -3
View File
@@ -196,7 +196,7 @@ export function createGeometryQueue({
function processGeometryBatch() {
layerState._geoQueueTimer = 0;
if (!layerState._viewer) {
if (!parts.renderer?.ready) {
stopGeometryLoadQueue();
return;
}
@@ -209,7 +209,7 @@ export function createGeometryQueue({
const batchResult = processCctvGeometryDrainBatch({
queue: layerState._geoQueue,
readOwnership: () => ({
trackedEntity: layerState._viewer.trackedEntity,
trackedEntity: parts.renderer.following(),
cockpitActive:
typeof document !== 'undefined' &&
document.body?.classList.contains('cockpit-mode'),
@@ -296,7 +296,7 @@ export function createGeometryQueue({
layerState._tilesReadyReenqueued = false;
if (!layerState._records.length) return;
const active = parts.selection.getActiveRecord();
const carto = layerState._viewer?.camera?.positionCartographic;
const carto = parts.renderer?.cameraCartographic();
const refLat = carto
? geo.math.toDegrees(carto.latitude)
: (active?.camera.lat ?? 0);
+2 -2
View File
@@ -43,7 +43,7 @@ export function createGround({ state: layerState, services, parts, source }) {
*/
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() {
if (!layerState._viewer || !layerState._records.length) return;
if (!parts.renderer?.ready || !layerState._records.length) return;
const regime = currentSurfaceRegime();
if (regime === layerState._lastAppliedRegime) return;
layerState._lastAppliedRegime = regime;
+2 -2
View File
@@ -25,13 +25,13 @@ export function createHover({ state: layerState, services, parts, source }) {
!position
)
return;
if (!layerState._viewer || layerState._viewer.isDestroyed()) return;
if (!parts.renderer?.ready) return;
const now = Date.now();
if (now - layerState._hoverLastPickAt < HOVER_PICK_THROTTLE_MS) return;
layerState._hoverLastPickAt = now;
let picked = null;
try {
picked = layerState._viewer.scene.pick(position);
picked = parts.renderer.pick(position);
} catch {
picked = null;
}
+2 -3
View File
@@ -20,7 +20,7 @@ import { createIngestion } from './ingestion.js';
import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */
export function createCctvLayer({ services, source }) {
export function createCctvLayer({ services, source, rendererFactory }) {
if (
!['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'].every(
(key) => typeof source?.[key] === 'function',
@@ -29,7 +29,7 @@ export function createCctvLayer({ services, source }) {
throw new TypeError('A camera source is required');
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
const context = { state, services, parts, source, rendererFactory };
parts.calibration = createCalibration(context);
parts.testing = createTesting(context);
parts.projection = createProjection(context);
@@ -94,7 +94,6 @@ export function createCctvLayer({ services, source }) {
clearProbeClampOnDeactivation:
parts.geometry.clearProbeClampOnDeactivation,
cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation,
bindCctvWorldClickGesture: parts.selection.bindCctvWorldClickGesture,
setActiveCamera: parts.selection.setActiveCamera,
deactivateActiveCamera: parts.selection.deactivateActiveCamera,
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 { CCTV_OVERLAY_SOURCE_ID } from '../../data/cctvCards.js';
import { GIZMO_ID_PREFIX } from '../../data/cctvGizmo.js';
import {
GROUND_PRIOR_INIT_WAIT_MS,
CAMERA_ICON,
IDLE_CAMERA_COLOR,
CALIBRATION_RANGE_FLOOR_M,
} from './policy.js';
@@ -14,8 +12,8 @@ export function createLifecycle({
services,
parts,
source,
rendererFactory,
}) {
const { registerSpriteCollection, restoreSpriteOrder } = services.sprites;
const { activateCctvCameraFromWorldClick } = services.activation;
const { resolvePickId, registerPickOwner, unregisterPickOwner } =
services.picking;
@@ -38,9 +36,6 @@ export function createLifecycle({
layerState._lastError = null;
layerState._lastFocusStyleAt = 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
// recomputes it against the then-current scene.
layerState._lastAppliedRegime = null;
@@ -52,283 +47,277 @@ export function createLifecycle({
* handling, and performs initial health sync. Coverage entities stay lazy.
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
*/
async init(viewer) {
layerState._sourceAbort?.abort();
const sourceAbort = new AbortController();
layerState._sourceAbort = sourceAbort;
if (typeof document !== 'undefined')
document.addEventListener(
'visibilitychange',
parts.cards.handleVisibilityChange,
async init(viewer, options = {}) {
if (parts.renderer) throw new Error('CCTV layer is already initialized');
if (typeof rendererFactory !== 'function')
throw new TypeError('A CCTV renderer factory is required');
const renderer = rendererFactory({
state: layerState,
services,
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();
layerState._viewer.scene.primitives.add(layerState._billboards);
registerSpriteCollection('cctv', layerState._billboards);
const sources = await parts.catalog.loadCameraSources();
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]),
);
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;
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);
}
parts.model.ensureCameraPose(camera);
}
// Task 5 (height-datum fix): batch ALL camera coords through the Re:Earth
// ellipsoidal ground-prior resolver (network-cached — NOT a scene query;
// the catalog's orthometric groundElevationM feeds the geoid fallback
// chain). Bounded wait: a warm proxy cache resolves in milliseconds, so
// records are normally built WITH their prior (correct first paint in
// every regime); a cold/slow upstream loses the race and the batch
// applies post-hoc via applyLateGroundPriors instead of hanging init.
const priorsPromise = parts.ground.resolveGroundPriors(catalog);
const priors = await Promise.race([
priorsPromise,
new Promise((resolve) =>
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),
// Task 5 (height-datum fix): batch ALL camera coords through the Re:Earth
// ellipsoidal ground-prior resolver (network-cached — NOT a scene query;
// the catalog's orthometric groundElevationM feeds the geoid fallback
// chain). Bounded wait: a warm proxy cache resolves in milliseconds, so
// records are normally built WITH their prior (correct first paint in
// every regime); a cold/slow upstream loses the race and the batch
// applies post-hoc via applyLateGroundPriors instead of hanging init.
const priorsPromise = parts.ground.resolveGroundPriors(catalog);
const priors = await Promise.race([
priorsPromise,
new Promise((resolve) =>
setTimeout(() => resolve(null), GROUND_PRIOR_INIT_WAIT_MS),
),
viewshedPrimitive: null,
viewshedActiveTint: false,
};
layerState._records.push(record);
layerState._recordById.set(camera.id, record);
}
]);
sourceAbort.signal.throwIfAborted();
layerState._count = layerState._records.length;
if (layerState._records.length > 0) {
// Projection runtime + first frame fetch are deferred to enable() so
// initializing the catalog stays render-cheap.
layerState._activeCameraId = layerState._records[0].camera.id;
}
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 = 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
// when it lands (pure recomputes — applyLateGroundPriors guards against
// a torn-down/re-inited catalog).
if (!priors) {
const initRecords = layerState._records.slice();
priorsPromise
.then((late) => {
if (late) parts.ground.applyLateGroundPriors(initRecords, late);
})
.catch(() => {});
}
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,
};
layerState._records.push(record);
layerState._recordById.set(camera.id, record);
}
// Task 5: track the surface regime the initial geometry was computed for
// and listen for map-stack changes (main.js re-dispatches
// MapStackController.onChange as this CustomEvent). The handler compares
// regimes itself, so 'switching'/'error' emissions and same-regime stack
// 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,
);
}
layerState._count = layerState._records.length;
if (layerState._records.length > 0) {
// Projection runtime + first frame fetch are deferred to enable() so
// initializing the catalog stays render-cheap.
layerState._activeCameraId = layerState._records[0].camera.id;
}
// 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 = () => {
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,
// Task 5: if the prior batch lost init's bounded race, apply it post-hoc
// when it lands (pure recomputes — applyLateGroundPriors guards against
// a torn-down/re-inited catalog).
if (!priors) {
const initRecords = layerState._records.slice();
priorsPromise
.then((late) => {
if (late) parts.ground.applyLateGroundPriors(initRecords, late);
})
) {
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),
},
);
.catch(() => {});
}
await parts.health.syncHealthState(true);
parts.rendering.refreshCoverageStyles();
parts.presentation.notifyListeners();
restoreSpriteOrder(layerState._viewer);
console.log('[Data:CCTV] Initialized with', layerState._count, 'cameras');
// Task 5: track the surface regime the initial geometry was computed for
// and listen for map-stack changes (main.js re-dispatches
// MapStackController.onChange as this CustomEvent). The handler compares
// regimes itself, so 'switching'/'error' emissions and same-regime stack
// 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',
)
)
services.credits?.showOsmCredit?.(layerState._viewer, 'cctv');
parts.renderer?.showCredit();
layerState._lastUpdate = Date.now();
// Pick-ownership (H2): camera billboards use the camera id directly;
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
@@ -386,12 +375,12 @@ export function createLifecycle({
parts.cards.startCardFrameLoop();
parts.cards.refreshAmbientCards();
parts.presentation.notifyListeners();
restoreSpriteOrder(layerState._viewer);
parts.renderer.restoreOrder();
},
/** Disables the layer: hides entities, stops the projection loop and load queue. */
disable() {
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv');
parts.renderer?.hideCredit();
layerState._enabled = false;
unregisterPickOwner('cctv');
// 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
* 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) {
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv');
destroy() {
parts.renderer?.hideCredit();
layerState._sourceAbort?.abort();
if (typeof document !== 'undefined')
document.removeEventListener(
@@ -430,39 +419,27 @@ export function createLifecycle({
);
layerState._mapStackListener = null;
}
const teardownViewer = viewer || layerState._viewer;
if (layerState._horizonCullListener && teardownViewer?.camera?.moveEnd) {
teardownViewer.camera.moveEnd.removeEventListener(
layerState._horizonCullListener,
);
layerState._horizonCullListener = null;
}
if (layerState._moveStartListener && teardownViewer?.camera?.moveStart) {
teardownViewer.camera.moveStart.removeEventListener(
layerState._moveStartListener,
);
layerState._moveStartListener = null;
}
layerState._horizonCullListener?.();
layerState._horizonCullListener = null;
layerState._moveStartListener?.();
layerState._moveStartListener = null;
layerState._removeFocusAppearListener?.();
layerState._removeFocusAppearListener = null;
if (layerState._gizmo) {
layerState._gizmo.destroy();
layerState._gizmo = null;
}
layerState._calibrationMode = false;
releaseContinuousRender('cctv-adjust');
if (layerState._clickHandler) {
layerState._clickHandler.destroy();
layerState._clickHandler = null;
}
layerState._removeClick?.();
layerState._removeClick = null;
parts.projection.stopProjectionLoop();
parts.geometryQueue.stopGeometryLoadQueue();
parts.cards.teardownAmbientCards();
parts.geometry.destroyCoverageEntities();
if (layerState._billboards && teardownViewer) {
teardownViewer.scene.primitives.remove(layerState._billboards);
layerState._billboards = null;
}
parts.renderer?.destroy();
parts.renderer = null;
clearRuntimeState();
layerState._viewer = null;
layerState._enabled = false;
layerState._activeCameraId = null;
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 { staticFrameRefreshMs } from '../../data/cctvLod.js';
import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js';
@@ -327,7 +326,7 @@ export function createModel({ state: layerState, services, parts, source }) {
*/
function currentViewContext() {
const carto = layerState._viewer?.camera?.positionCartographic;
const carto = parts.renderer?.cameraCartographic();
if (!carto) return 'none';
const lat = geo.math.toDegrees(carto.latitude);
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
* 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.
* Discovers + caches the tileset lazily from scene
* primitives (the CCTV module holds only a `_viewer` reference). When no
* The renderer discovers and caches its active tileset. When no
* tileset is present (OSM fallback) this returns true so ground sampling is
* not permanently blocked.
*
@@ -370,25 +368,7 @@ export function createModel({ state: layerState, services, parts, source }) {
*/
function projectionTilesReady() {
if (
!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;
return parts.renderer?.tilesReady() ?? 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 —
* computed only on slider/save/activation, never per frame (§2b).
* @param {Object} camera - Camera pose.
* @param {Cesium.Cartesian3} capCenterPos - Plane center in ECEF.
* @returns {Cesium.Quaternion}
* @param {{x: number, y: number, z: number}} capCenterPos - Plane center in ECEF.
* @returns {{x: number, y: number, z: number, w: number}}
*/
function planeOrientationFor(camera, capCenterPos) {
@@ -422,9 +402,7 @@ export function createModel({ state: layerState, services, parts, source }) {
m[6] = normal.x;
m[7] = normal.y;
m[8] = normal.z;
// The quaternion becomes an entity's ConstantProperty value, so it stays a
// Cesium Quaternion there (that property clones and compares its value).
return geo.quaternion.fromRotationMatrix(m, new Cesium.Quaternion());
return geo.quaternion.fromRotationMatrix(m, geo.quaternion.create());
}
/**
@@ -442,7 +420,7 @@ export function createModel({ state: layerState, services, parts, source }) {
*/
function projectionLoopIsNeeded() {
if (!layerState._enabled || !layerState._viewer) return false;
if (!layerState._enabled || !parts.renderer?.ready) return false;
if (layerState._showProjection && parts.selection.getActiveRecord())
return true;
return focusPassIsNeeded(
+6 -23
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { CCTV_FOCUS_RESULT } from './policy.js';
@@ -14,7 +13,7 @@ export function createNavigation({
*/
function nearestCameraIdToViewer() {
const carto = layerState._viewer?.camera?.positionCartographic;
const carto = parts.renderer?.cameraCartographic();
if (!carto || !layerState._records.length) return null;
const lat = geo.math.toDegrees(carto.latitude);
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.
*/
function focusCctvRecord(viewer, record, duration = 2.2) {
if (!viewer || !record) return CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA;
function focusCctvRecord(renderer, record, duration = 2.2) {
if (!renderer || !record) return CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA;
if (
typeof document !== 'undefined' &&
document.body?.classList.contains('cockpit-mode')
@@ -52,35 +51,19 @@ export function createNavigation({
console.debug('[Data:CCTV] focus ignored while cockpit owns the camera');
return CCTV_FOCUS_RESULT.COCKPIT_ACTIVE;
}
if (viewer.trackedEntity) {
if (renderer.following()) {
console.debug(
'[Data:CCTV] focus ignored while a tracked entity owns the camera',
);
return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW;
}
const { camera } = record;
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,
},
);
renderer.focus(record, duration);
return CCTV_FOCUS_RESULT.FOCUSED;
}
function focusCamera(cameraId, duration = 2.2) {
return focusCctvRecord(
layerState._viewer,
parts.renderer?.ready ? parts.renderer : null,
layerState._recordById.get(cameraId),
duration,
);
+21 -11
View File
@@ -5,7 +5,7 @@ export {
MEDIA_ENDPOINT,
ACTIVE_FRAME_REFRESH_MS,
} 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_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
@@ -404,38 +404,48 @@ export const CAMERA_SEEDS = [
// Visual style constants
// ---------------------------------------------------------------------------
// prettier-ignore
export const IDLE_CAMERA_COLOR =
Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.88);
rgbaFromCss('#6be8ff').withAlpha(0.88);
// prettier-ignore
export const ACTIVE_CAMERA_COLOR =
Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95);
rgbaFromCss('#ffd97a').withAlpha(0.95);
// prettier-ignore
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 =
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.2);
rgbaFromCss('#2fe0ff').withAlpha(0.2);
// prettier-ignore
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 =
Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.58);
rgbaFromCss('#8dff87').withAlpha(0.58);
// prettier-ignore
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
// through buildings while in monitor fallback mode.
// prettier-ignore
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 =
Cesium.Color.fromCssColorString('#d7ff8d').withAlpha(0.26);
rgbaFromCss('#d7ff8d').withAlpha(0.26);
// prettier-ignore
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
+46 -47
View File
@@ -1,10 +1,8 @@
import { attachCctvVideo } from './videoPlayback.js';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import {
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
PLANE_OUTLINE_COLOR,
PROJECTION_CANVAS_WIDTH,
PROJECTION_CANVAS_HEIGHT,
PROJECTION_TEXTURE_SWAP_MS,
@@ -72,17 +70,12 @@ export function createProjection({
);
const positions =
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
runtime.planeEntity.position = positions.capCenter;
runtime.planeEntity.orientation = parts.model.planeOrientationFor(
record.camera,
positions.capCenter,
parts.renderer.updatePlane(
runtime,
geometry,
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) {
geo.cartesian3.clone(positions.label, runtime.labelPosition);
}
@@ -109,7 +102,8 @@ export function createProjection({
function setPlaneVisible(runtime, visible) {
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 (layerState._projectionOverlayOwnerId !== runtime.cameraId) {
layerState._cctvOverlayHost.setEntries(
@@ -139,26 +133,14 @@ export function createProjection({
name: record.camera.name,
position: () => runtime.labelPosition,
});
runtime.planeEntity = layerState._viewer.entities.add({
id: `cctv-${record.camera.id}-plane`,
properties: { cctvCameraId: record.camera.id },
show: false,
position: positions.capCenter,
orientation: parts.model.planeOrientationFor(
record.camera,
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,
},
});
runtime.planeVisible = false;
runtime.planeEntity = parts.renderer.createPlane(
record,
runtime,
geometry,
positions,
parts.model.planeOrientationFor(record.camera, positions.capCenter),
);
return runtime.planeEntity;
}
@@ -177,7 +159,7 @@ export function createProjection({
*/
function createProjectionRuntime(record) {
if (!layerState._viewer) return null;
if (!parts.renderer?.ready) return null;
const canvas = document.createElement('canvas');
canvas.width = PROJECTION_CANVAS_WIDTH;
canvas.height = PROJECTION_CANVAS_HEIGHT;
@@ -219,6 +201,7 @@ export function createProjection({
};
parts.frames.paintProjectionPlaceholder(ctx, record.camera);
runtime.planeMaterial = parts.renderer.createPlaneMaterial(canvas);
if (mode === 'video') {
const video = document.createElement('video');
@@ -230,15 +213,25 @@ export function createProjection({
// attributes at first upload. Set them from the real stream dimensions
// and rebind on any resolution change (camera switch, adaptive source).
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;
video.width = video.videoWidth;
video.height = video.videoHeight;
runtime.planeMaterial.image = runtime.canvas;
runtime.planeMaterial.image = video;
parts.renderer.setPlaneImage(runtime, runtime.canvas);
parts.renderer.setPlaneImage(runtime, video);
};
video.addEventListener('loadedmetadata', bindVideoTexture);
video.addEventListener('resize', bindVideoTexture);
runtime.removeVideoBindings = () => {
video.removeEventListener('loadedmetadata', bindVideoTexture);
video.removeEventListener('resize', bindVideoTexture);
runtime.removeVideoBindings = null;
};
runtime.video = video;
runtime.playback = attachCctvVideo(
video,
@@ -247,20 +240,23 @@ export function createProjection({
{
onFailure: () => {
if (runtime.disposed) return;
runtime.removeVideoBindings?.();
runtime.video = null;
runtime.mode = 'image';
runtime.image = new Image();
runtime.image.decoding = 'async';
runtime.image.onload = () => {
if (runtime.disposed) return;
runtime.imageLoading = false;
runtime.imageReady = true;
runtime.imageStamp = Date.now();
};
runtime.image.onerror = () => {
if (runtime.disposed) return;
runtime.imageLoading = false;
runtime.imageReady = false;
};
runtime.planeMaterial.image = runtime.canvas;
parts.renderer.setPlaneImage(runtime, runtime.canvas);
parts.frames.refreshProjectionImage(record, true);
parts.presentation.notifyListeners();
},
@@ -271,11 +267,13 @@ export function createProjection({
img.decoding = 'async';
img.crossOrigin = 'anonymous';
img.onload = () => {
if (runtime.disposed) return;
runtime.imageLoading = false;
runtime.imageReady = true;
runtime.imageStamp = Date.now();
};
img.onerror = () => {
if (runtime.disposed) return;
runtime.imageLoading = false;
runtime.imageReady = false;
};
@@ -295,12 +293,12 @@ export function createProjection({
);
const positions =
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
runtime.planeMaterial = new Cesium.ImageMaterialProperty({
image: canvas,
transparent: true,
color: Cesium.Color.WHITE.withAlpha(0.95),
});
createProjectionPlane(record, runtime, geometry, positions);
try {
createProjectionPlane(record, runtime, geometry, positions);
} catch (error) {
destroyProjectionRuntime(runtime);
throw error;
}
return runtime;
}
@@ -332,6 +330,7 @@ export function createProjection({
function destroyProjectionRuntime(runtime) {
if (!runtime || runtime.disposed) return;
runtime.disposed = true;
runtime.removeVideoBindings?.();
runtime.playback?.dispose();
runtime.playback = null;
if (runtime.image) {
@@ -344,8 +343,8 @@ export function createProjection({
runtime.video.removeAttribute('src');
runtime.video.load();
}
if (runtime.planeEntity && layerState._viewer) {
layerState._viewer.entities.remove(runtime.planeEntity);
if (runtime.planeEntity) {
parts.renderer?.removePlane(runtime);
runtime.planeEntity = null;
}
if (layerState._projectionOverlayOwnerId === runtime.cameraId)
@@ -366,7 +365,7 @@ export function createProjection({
if (revision !== loopRevision) return;
layerState._projectionRaf = 0;
layerState._projectionTimer = 0;
if (!layerState._viewer || !parts.model.projectionLoopIsNeeded()) {
if (!parts.renderer?.ready || !parts.model.projectionLoopIsNeeded()) {
releaseContinuousRender('cctv-projection');
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: {
renderer: { ready: true },
model: { projectionLoopIsNeeded: () => state._enabled },
selection: { getActiveRecord: () => record },
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 {
ACTIVE_CAMERA_COLOR,
@@ -37,27 +35,26 @@ export function createRendering({
const target = getFocusTarget();
if (
!layerState._enabled ||
!layerState._viewer ||
!parts.renderer?.ready ||
!focusPassIsNeeded(target, layerState._activeFocusStyleCount)
)
return;
if (nowMs - layerState._lastFocusStyleAt < 80) return;
layerState._lastFocusStyleAt = nowMs;
const scene = layerState._viewer.scene;
const camera = layerState._viewer.camera;
const result = applyCctvFocusDeemphasis({
records: layerState._records,
target,
previousActiveCount: layerState._activeFocusStyleCount,
nowMs,
// prettier-ignore
screenPositionFor: (position) =>
Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
parts.renderer.project(
position,
layerState._scratchFocusScreen,
),
// prettier-ignore
cameraDistanceFor: (position) =>
Cesium.Cartesian3.distance(camera.positionWC, position),
parts.renderer.distance(position),
baseColorFor: (record) =>
record.camera.id === layerState._activeCameraId
? ACTIVE_CAMERA_COLOR
@@ -136,17 +133,17 @@ export function createRendering({
*/
function refreshHorizonCulling() {
// prettier-ignore
if (
!layerState._viewer ||
layerState._viewer.isDestroyed() ||
!parts.renderer?.ready ||
!layerState._records.length
)
return;
const occluder = horizonOccluder(layerState._viewer.camera);
const horizonVisible = parts.renderer.horizonTest();
for (const record of layerState._records) {
const bb = record.billboard;
if (!bb) continue;
const visible = occluder.isPointVisible(bb.position);
const visible = horizonVisible(bb.position);
if (bb.show !== visible) bb.show = visible;
}
}
@@ -173,8 +170,10 @@ export function createRendering({
}
for (const entity of record?.coverageEntities || []) entity.show = false;
if (record?.viewshedPrimitive) destroyVolume?.(record);
if (record?.projection?.planeEntity)
if (record?.projection?.planeEntity) {
record.projection.planeEntity.show = false;
record.projection.planeVisible = false;
}
}
}
@@ -185,7 +184,7 @@ export function createRendering({
layerState._activeCameraId,
);
parts.projection.clearProjectionOverlay();
if (layerState._billboards) layerState._billboards.show = false;
parts.renderer?.setMarkersVisible(false);
parts.projection.pauseInactiveProjectionFeeds(null);
}
@@ -239,56 +238,40 @@ export function createRendering({
// rather than rendering identically to a surveyed one (#639). Colors,
// widths, and the active/idle emphasis are unchanged.
const bearingEstimated = isHeadingEstimated(record.camera);
const lineMaterial = (color) =>
bearingEstimated
? new Cesium.PolylineDashMaterialProperty({ color })
: color;
for (const entity of record.coverageEntities || []) {
// The frustum wireframe is part of the projection representation —
// force it on for the active camera and let it read through geometry
// via depthFailMaterial (polylines have no disableDepthTestDistance).
entity.show = !!(
const visible = !!(
layerState._enabled &&
((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;
if (entity._coverageRole === 'cap') {
entity.polyline.material = lineMaterial(
hue
? isActive
? hue.lineActive
: hue.line
: isActive
? ACTIVE_COVERAGE_CENTER
: IDLE_COVERAGE_CENTER_MUTED,
);
entity.polyline.width = isActive ? 2.2 : 1.0;
entity.polyline.depthFailMaterial = planeShowing
? hue
? hue.line.withAlpha(0.26)
: ACTIVE_COVERAGE_CENTER_DEPTHFAIL
: undefined;
} else {
entity.polyline.material = lineMaterial(
hue
? isActive
? hue.lineActive
: hue.line.withAlpha(0.6)
: isActive
? ACTIVE_COVERAGE_EDGE
: IDLE_COVERAGE_EDGE_MUTED,
);
entity.polyline.width = isActive ? 1.8 : 0.9;
entity.polyline.depthFailMaterial = planeShowing
? hue
? hue.line.withAlpha(0.18)
: ACTIVE_COVERAGE_EDGE_DEPTHFAIL
: undefined;
}
const cap = entity._coverageRole === 'cap';
const tint = hue
? isActive
? hue.lineActive
: cap
? hue.line
: hue.line.withAlpha(0.6)
: cap
? isActive
? ACTIVE_COVERAGE_CENTER
: IDLE_COVERAGE_CENTER_MUTED
: isActive
? ACTIVE_COVERAGE_EDGE
: IDLE_COVERAGE_EDGE_MUTED;
const depthFail = !planeShowing
? undefined
: hue
? hue.line.withAlpha(cap ? 0.26 : 0.18)
: cap
? ACTIVE_COVERAGE_CENTER_DEPTHFAIL
: ACTIVE_COVERAGE_EDGE_DEPTHFAIL;
parts.renderer.styleLine(entity, {
visible,
width: cap ? (isActive ? 2.2 : 1.0) : isActive ? 1.8 : 0.9,
tint,
dashed: bearingEstimated,
depthFail,
});
}
// Viewshed volume lifecycle: exists iff enabled + viewshed mode + in the
@@ -316,8 +299,7 @@ export function createRendering({
}
}
if (layerState._billboards)
layerState._billboards.show = !!layerState._enabled;
parts.renderer?.setMarkersVisible(!!layerState._enabled);
parts.projection.pauseInactiveProjectionFeeds(activeId);
}
return {
+1 -72
View File
@@ -1,9 +1,3 @@
import {
bindTrackingClickGesture,
isTrackingClickGesture,
} from '../../data/trackingClickGesture.js';
import * as Cesium from 'cesium';
export function createSelection({
state: layerState,
services,
@@ -33,27 +27,6 @@ export function createSelection({
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
* its frame, and updates styles.
@@ -206,54 +179,10 @@ export function createSelection({
*/
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' ? 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 parts.renderer?.extractPickedCameraId(picked) ?? null;
}
return {
getActiveRecord,
bindCctvWorldClickGesture,
setActiveCamera,
deactivateActiveCamera,
cctvEmptyClickDeselects,
+11 -2
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCctvSource, createCctvLayer } from './index.js';
import { createCctvCesiumRenderer } from './render/cesium.js';
const camera = {
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 b = createCctvLayer({ services, source });
const a = createCctvLayer({
services,
source,
rendererFactory: createCctvCesiumRenderer,
});
const b = createCctvLayer({
services,
source,
rendererFactory: createCctvCesiumRenderer,
});
assert.equal(listeners.size, 0);
const viewer = {
scene: { primitives: { add: (value) => value, remove: () => true } },
+1 -7
View File
@@ -13,10 +13,6 @@ export function createState({ services }) {
// Module-scoped mutable state
// ---------------------------------------------------------------------------
layerState._viewer = null;
layerState._billboards = null;
layerState._records = [];
layerState._recordById = new Map();
@@ -48,7 +44,7 @@ export function createState({ services }) {
layerState._lastViewContext = '';
layerState._clickHandler = null;
layerState._removeClick = null;
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
// tileset is destroyed / the layer tears down.
layerState._activeTileset = null;
// Task 5: last surface regime the record geometry was recomputed for. The
// map-stack change listener compares the CURRENT regime (derived live from
// 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
* 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) {
layerState._viewer = viewer;
useRenderer(viewer);
const geometry =
record.frustumGeometry ||
parts.geometry.computeFrustumGeometry(
@@ -40,9 +60,9 @@ export function createTesting({ state: layerState, services, parts, source }) {
const runtime = {
cameraId: String(record.camera.id),
planeEntity: null,
labelPosition: new Cesium.Cartesian3(),
labelPosition: geo.cartesian3.create(),
overlayEntry: null,
planeMaterial: new Cesium.ColorMaterialProperty(Cesium.Color.WHITE),
planeMaterial: parts.renderer.createTestPlaneMaterial(),
};
parts.projection.createProjectionPlane(
record,
@@ -90,7 +110,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
coverageMode = 'on',
showProjection = false,
} = {}) {
layerState._viewer = viewer;
useRenderer(viewer);
layerState._records = Array.isArray(records) ? records : [];
layerState._recordById = new Map(
layerState._records
@@ -99,7 +119,6 @@ export function createTesting({ state: layerState, services, parts, source }) {
);
layerState._coverageEntities = [];
layerState._projectionEntities = [];
layerState._billboards = null;
layerState._activeCameraId = activeCameraId;
layerState._autoHopSuspended = false;
layerState._enabled = !!enabled;