Draw CCTV cameras from a GPU icon set

This commit is contained in:
Sameh Khamis committed 2026-11-03 21:47:16 -08:00
1 parent 89b07fddfd
commit 00d1bee127
16 files changed
+291 -168

No files matched your search

+6
View File
@@ -1890,6 +1890,12 @@
"src/layers/cctv/videoPlayback.js",
"src/layers/cctv/rendering.js",
"src/data/billboardMirror.js",
"src/data/iconSetPrimitive.js",
"src/data/iconSet.js",
"src/data/iconSegment.js",
"src/data/iconAtlas.js",
"src/data/aircraftRecession.js",
"src/data/motionModel.js",
"src/layers/cctv/selection.js",
"src/layers/cctv/pickState.js",
"src/layers/cctv/source.js",
+1
View File
@@ -65,6 +65,7 @@ export const materializeCctvVisibleCoverageEntities =
export const _extractPickedCameraIdForTest =
layer._extractPickedCameraIdForTest;
export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest;
export const _cctvIconsForTest = layer._cctvIconsForTest;
export const focusCctvRecord = layer.focusCctvRecord;
export const maybeAutoHop = layer.maybeAutoHop;
export const cctvCycleIndex = layer.cctvCycleIndex;
+16 -17
View File
@@ -32,6 +32,7 @@ import cctvLayer, {
_extractPickedCameraIdForTest,
_updateCctvProjectionPlaneForTest,
_setCctvCoverageStateForTest,
_cctvIconsForTest,
_pushAmbientCardEntriesForTest,
_setCctvOverlayHostForTest,
activationProbeClampRange,
@@ -79,7 +80,6 @@ import {
IDLE_CAMERA_COLOR,
LIVE_CAMERA_COLOR,
} from '../layers/cctv/policy.js';
import { mirrorBillboard } from './billboardMirror.js';
import { cctvPicks } from '../layers/cctv/pickState.js';
const ASPECT = 16 / 9;
@@ -357,7 +357,6 @@ test('default COVERAGE refresh materializes the active and visible camera frustu
test('camera icons are violet for video feeds, cyan for stills, amber when active', () => {
const record = (id, feedType) => ({
camera: { ...UNCLAMPED_CAMERA, id, feedType, groundElevationM: 0 },
billboard: mirrorBillboard({}),
coverageEntities: [],
projection: null,
});
@@ -377,9 +376,15 @@ test('camera icons are violet for video feeds, cyan for stills, amber when activ
coverageMode: 'off',
});
refreshCoverageStyles();
assert.equal(idleVideo.billboard.color, LIVE_CAMERA_COLOR);
assert.equal(idleStill.billboard.color, IDLE_CAMERA_COLOR);
assert.equal(activeVideo.billboard.color, ACTIVE_CAMERA_COLOR);
const icons = _cctvIconsForTest();
const color = (record) => icons.color(icons.row(record.camera.id));
const bytes = (c) =>
[c.red, c.green, c.blue, c.alpha].map(Cesium.Color.floatToByte);
assert.deepEqual(bytes(color(idleVideo)), bytes(LIVE_CAMERA_COLOR));
assert.deepEqual(bytes(color(idleStill)), bytes(IDLE_CAMERA_COLOR));
assert.deepEqual(bytes(color(activeVideo)), bytes(ACTIVE_CAMERA_COLOR));
assert.equal(icons.scale(icons.row(activeVideo.camera.id)), 1.25);
assert.equal(icons.scale(icons.row(idleVideo.camera.id)), 1);
} finally {
_setCctvCoverageStateForTest({ enabled: false });
}
@@ -684,7 +689,6 @@ test('CCTV disable hide sweep hides record visuals and destroys viewsheds withou
camera: { id: 'a' },
activationDone: true,
probeClampRangeM: 105,
billboard: mirrorBillboard({ color: 'keep-color', scale: 1.25 }),
coverageEntities: [{ show: true }, { show: true }],
projection: { planeEntity: { show: true } },
viewshedPrimitive: viewshedA,
@@ -693,7 +697,6 @@ test('CCTV disable hide sweep hides record visuals and destroys viewsheds withou
camera: { id: 'b' },
activationDone: true,
probeClampRangeM: 146,
billboard: mirrorBillboard({ color: 'keep-color', scale: 1 }),
projection: null,
viewshedPrimitive: viewshedB,
},
@@ -727,13 +730,6 @@ test('CCTV disable hide sweep hides record visuals and destroys viewsheds withou
records.map((record) => record.activationDone),
[false, true],
);
assert.deepEqual(
records.map((record) => record.billboard),
[
{ color: 'keep-color', scale: 1.25 },
{ color: 'keep-color', scale: 1 },
],
);
});
test('real active monitor plane owns one protected host label and no native label entity', () => {
@@ -1130,14 +1126,12 @@ test('CCTV empty-click gate excludes every identified scene object, ADJUST, and
});
test('CCTV camera extraction requires layer ownership, not a colliding sibling ID', () => {
const billboard = { id: 'shared-id' };
const ownedCoverageEntity = { id: 'cctv-shared-id-cap' };
cctvPicks.write(ownedCoverageEntity).cameraId = 'shared-id';
const strayEntity = { id: 'cctv-shared-id-stray' };
cctvPicks.write(strayEntity).cameraId = 'shared-id';
const record = {
...makeDeselectRecord('shared-id'),
billboard,
coverageEntities: [ownedCoverageEntity],
};
_setCctvCoverageStateForTest({
@@ -1146,12 +1140,17 @@ test('CCTV camera extraction requires layer ownership, not a colliding sibling I
});
try {
assert.equal(
_extractPickedCameraIdForTest({ id: 'shared-id', primitive: billboard }),
_extractPickedCameraIdForTest({
id: 'shared-id',
iconSet: _cctvIconsForTest(),
primitive: { id: 'shared-id' },
}),
'shared-id',
);
assert.equal(
_extractPickedCameraIdForTest({
id: 'shared-id',
iconSet: {},
primitive: { id: 'shared-id' },
}),
null,
+11 -5
View File
@@ -6,11 +6,11 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import cctvLayer, {
_cctvIconsForTest,
_setCctvCoverageStateForTest,
_viewRecordIdsForTest,
refreshHorizonCulling,
} from './cctv.js';
import { mirrorBillboard } from './billboardMirror.js';
const WIDTH = 800;
const HEIGHT = 600;
@@ -41,7 +41,6 @@ function recordsAt(
mountHeightM: 8,
},
position,
billboard: mirrorBillboard({ position, show: true }),
coverageEntities: [],
projection: null,
};
@@ -60,7 +59,7 @@ function viewerAt(heightM, screenByPosition, shiftX = 0) {
scene: {
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
cartesianToCanvasCoordinates: (position) => {
// The mirror holds its own copy of each position, so match by value.
// Match by value: a record's position may be a copy of the fixture's.
const key = [...screenByPosition.keys()].find((candidate) =>
Cesium.Cartesian3.equals(candidate, position),
);
@@ -73,7 +72,10 @@ function viewerAt(heightM, screenByPosition, shiftX = 0) {
function shownIds(records) {
return records
.filter((record) => record.billboard.show)
.filter((record) => {
const icons = _cctvIconsForTest();
return icons.shown(icons.row(record.camera.id));
})
.map((record) => record.camera.id)
.sort();
}
@@ -165,7 +167,11 @@ test('the active camera always shows, even in a crowded cell', () => {
activeCameraId: active.camera.id,
});
refreshHorizonCulling();
assert.ok(active.billboard.show, `${active.camera.id} shows while active`);
const icons = _cctvIconsForTest();
assert.ok(
icons.shown(icons.row(active.camera.id)),
`${active.camera.id} shows while active`,
);
}
});
+32 -25
View File
@@ -4,7 +4,6 @@ import { applyVesselFocusDeemphasis } from './aisLiveVessels.js';
import { applyCctvFocusDeemphasis } from './cctv.js';
import { applySatellitePointFocusDeemphasis } from './satellites.js';
import { DEFAULT_FOCUS_DEEMPHASIS_PARAMS } from './focusDeemphasis.js';
import { mirrorBillboard } from './billboardMirror.js';
import { createIconSet } from './iconSet.js';
const params = { ...DEFAULT_FOCUS_DEEMPHASIS_PARAMS, paddingPx: 0 };
@@ -29,7 +28,11 @@ function assertResumeGate(name, makePass) {
const { pass, readAlpha } = makePass();
let activeCount = 0;
const tick = (nowMs, focusTarget) => {
const result = pass({ nowMs, target: focusTarget, previousActiveCount: activeCount });
const result = pass({
nowMs,
target: focusTarget,
previousActiveCount: activeCount,
});
activeCount = result.activeCount;
return result;
};
@@ -40,7 +43,11 @@ function assertResumeGate(name, makePass) {
assert.equal(readAlpha(), params.dimFloor);
const releaseStart = tick(params.attackMs + 1, null);
assert.equal(releaseStart.ran, true, 'active count keeps the no-target release pass alive');
assert.equal(
releaseStart.ran,
true,
'active count keeps the no-target release pass alive',
);
assert.equal(releaseStart.activeCount, 1);
const restored = tick(params.attackMs + params.releaseMs + 2, null);
@@ -48,16 +55,23 @@ function assertResumeGate(name, makePass) {
assert.equal(restored.activeCount, 0);
assert.equal(readAlpha(), 1);
assert.equal(tick(params.attackMs + params.releaseMs + 82, null).ran, false);
assert.equal(
tick(params.attackMs + params.releaseMs + 82, null).ran,
false,
);
});
}
assertResumeGate('vessel', () => {
const icons = createIconSet();
icons.setSize(icons.add('353136000', { id: '353136000' }), 32, 32);
const record = { iconId: '353136000', position: { x: 1, y: 2, z: 3 }, visible: true };
const record = {
iconId: '353136000',
position: { x: 1, y: 2, z: 3 },
visible: true,
};
return {
pass: ({ nowMs, target: focusTarget, previousActiveCount }) => (
pass: ({ nowMs, target: focusTarget, previousActiveCount }) =>
applyVesselFocusDeemphasis({
records: [record],
icons,
@@ -67,36 +81,30 @@ assertResumeGate('vessel', () => {
screenPositionFor: () => ({ x: 50, y: 50 }),
cameraDistanceFor: () => 1200,
params,
})
),
}),
readAlpha: () => icons.focus(icons.row('353136000')),
};
});
assertResumeGate('CCTV', () => {
const billboard = mirrorBillboard({
position: { x: 1, y: 2, z: 3 },
show: true,
width: 24,
height: 24,
scale: 1,
color: color(),
});
const record = { camera: { id: 'cam-1' }, billboard };
const icons = createIconSet();
const row = icons.add('cam-1', { id: 'cam-1' });
icons.setSize(row, 24, 24);
icons.setShown(row, true);
const record = { camera: { id: 'cam-1' }, position: { x: 1, y: 2, z: 3 } };
return {
pass: ({ nowMs, target: focusTarget, previousActiveCount }) => (
pass: ({ nowMs, target: focusTarget, previousActiveCount }) =>
applyCctvFocusDeemphasis({
records: [record],
icons,
target: focusTarget,
previousActiveCount,
nowMs,
screenPositionFor: () => ({ x: 50, y: 50 }),
cameraDistanceFor: () => 1200,
baseColorFor: () => color(),
params,
})
),
readAlpha: () => billboard.color.alpha,
}),
readAlpha: () => icons.focus(icons.row('cam-1')),
};
});
@@ -108,7 +116,7 @@ assertResumeGate('satellite', () => {
color: color(),
};
return {
pass: ({ nowMs, target: focusTarget, previousActiveCount }) => (
pass: ({ nowMs, target: focusTarget, previousActiveCount }) =>
applySatellitePointFocusDeemphasis({
points: new Map([[42, point]]),
trackedId: null,
@@ -119,8 +127,7 @@ assertResumeGate('satellite', () => {
cameraDistanceFor: () => 1200,
baseColorFor: () => color(),
params,
})
),
}),
readAlpha: () => point.color.alpha,
};
});
+8 -1
View File
@@ -259,10 +259,14 @@ export class IconSetPrimitive {
update(frameState) {
const set = this.set;
// Icons are screen overlays, not world surfaces: they stay out of the
// offscreen ray picks behind pickFromRay and sampleHeight, so a layer's
// ground and mesh probes never hit its own (or another layer's) icons.
if (
!this.show ||
!set.show ||
(!frameState.passes.render && !frameState.passes.pick)
(!frameState.passes.render && !frameState.passes.pick) ||
frameState.passes.offscreen
)
return;
const context = frameState.context;
@@ -446,6 +450,9 @@ export class IconSetPrimitive {
handle = {
pickId: context.createPickId({
id,
// Which set drew the pick: ownership for layers whose row ids are
// upstream ids a sibling may share.
iconSet: set,
get meta() {
const at = set.row(id);
return at >= 0 ? set.metaAt(at) : undefined;
+6 -8
View File
@@ -16,7 +16,6 @@ import {
poseHash,
requiredPlaneLift,
} from '../../data/cctvFootprint.js';
import { setBillboard } from '../../data/billboardMirror.js';
import { cctvPicks } from './pickState.js';
export function createGeometry({ state: layerState, services, parts, source }) {
@@ -328,7 +327,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
/**
* Recomputes the pure frustum geometry from the camera pose + the given ground
* altitude and writes it into the scene: billboard position, the 5 wireframe
* altitude and writes it into the scene: icon position, the 5 wireframe
* polylines (4 corner rays + closed far-plane rectangle), and the monitor
* plane placement. This is the ONLY place v2 geometry is written — called on
* slider input / save / activation / the one-shot ground snap, never per frame.
@@ -348,9 +347,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
record.frustumPositions = positions;
record.position = positions.mount;
record.camera.absoluteHeightM = geometry.mount.alt;
if (record.billboard) {
setBillboard(record.billboard, 'position', positions.mount);
}
parts.rendering.moveCameraIcon(record);
if (record.coverageEntities?.length >= 5) {
record.coverageEntities[0].polyline.positions = [
positions.mount,
@@ -458,7 +455,6 @@ export function createGeometry({ state: layerState, services, parts, source }) {
(record.groundMeshSampleRequestCount || 0) + 1;
const viewerCarto = layerState._viewer?.camera?.positionCartographic;
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], {
@@ -644,8 +640,9 @@ 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];
// world (3D tiles), not our own polylines/planes. Icons never draw into
// ray picks.
const exclude = [...layerState._coverageEntities];
for (const runtime of layerState._projectionEntities) {
if (runtime?.planeEntity) exclude.push(runtime.planeEntity);
}
@@ -699,6 +696,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
record.frustumGeometry = geometry;
record.frustumPositions = positions;
record.position = positions.mount;
parts.rendering.moveCameraIcon(record);
}
const addPolyline = (role, linePositions) => {
+1 -3
View File
@@ -4,7 +4,6 @@ import {
planeSupportPoints,
poseHash,
} from '../../data/cctvFootprint.js';
import { setBillboard } from '../../data/billboardMirror.js';
/** Cooldown before a provisional (geoid-fallback / partial) footprint is retried. */
const FOOTPRINT_RETRY_MS = 60_000;
@@ -239,8 +238,7 @@ export function createGround({ state: layerState, services, parts, source }) {
undefined,
record.position,
);
if (record.billboard)
setBillboard(record.billboard, 'position', record.position);
parts.rendering.moveCameraIcon(record);
moved += 1;
}
continue;
+1
View File
@@ -110,6 +110,7 @@ export function createCctvLayer({ services, source }) {
_extractPickedCameraIdForTest:
parts.testing._extractPickedCameraIdForTest,
_setCctvCoverageStateForTest: parts.testing._setCctvCoverageStateForTest,
_cctvIconsForTest: parts.testing._cctvIconsForTest,
focusCctvRecord: parts.navigation.focusCctvRecord,
maybeAutoHop: parts.navigation.maybeAutoHop,
cctvCycleIndex: parts.navigation.cctvCycleIndex,
+10 -39
View File
@@ -4,15 +4,12 @@ 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,
VIEW_MOTION_REFRESH_MS,
VIEW_MOTION_MIN_MOVE_FRACTION,
VIEW_MOTION_MIN_TURN_DEG,
VIEW_MOTION_COST_FACTOR,
} from './policy.js';
import { addBillboard } from '../../data/billboardMirror.js';
const MIN_TURN_COS = Math.cos((VIEW_MOTION_MIN_TURN_DEG * Math.PI) / 180);
@@ -134,7 +131,7 @@ export function createLifecycle({
const methods = {
/**
* Initializes the CCTV layer: loads camera sources, builds the catalog,
* restores calibration from localStorage, creates billboards, sets up click
* restores calibration from localStorage, creates camera icons, sets up click
* handling, and performs initial health sync. Coverage entities stay lazy.
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
*/
@@ -156,16 +153,10 @@ export function createLifecycle({
layerState._lastViewContext = '';
layerState._calibrationById = parts.calibration.loadCalibrationStore();
layerState._billboards = new Cesium.BillboardCollection();
// Cesium switches a property's buffer-usage hint whenever it starts or
// stops changing from one frame to the next, and every switch rewrites
// the vertex data of every billboard. The view pass toggles `show` on a
// few icons of a large catalog at a time, so on a 33K catalog each pass
// cost two full rewrites. With the hint fixed, Cesium uploads only the
// icons that changed.
layerState._billboards.computeNewBuffersUsage = () => false;
layerState._viewer.scene.primitives.add(layerState._billboards);
registerSpriteCollection('cctv', layerState._billboards);
registerSpriteCollection(
'cctv',
parts.rendering.createCameraIcons(layerState._viewer),
);
const sources = await parts.catalog.loadCameraSources();
const catalogFromSources = parts.catalog.buildCatalogFromSources(sources);
@@ -238,27 +229,9 @@ export function createLifecycle({
camera.lat,
camera.absoluteHeightM,
);
const billboard = addBillboard(layerState._billboards, {
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.
@@ -297,6 +270,7 @@ export function createLifecycle({
};
layerState._records.push(record);
layerState._recordById.set(camera.id, record);
parts.rendering.addCameraIcon(record);
}
layerState._count = layerState._records.length;
@@ -334,7 +308,7 @@ export function createLifecycle({
}
// 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
// the icons' always-on-top drawing) + one initial pass so the
// first paint is already culled.
// A re-init against a new viewer moves the move listeners to its camera.
if (
@@ -493,7 +467,7 @@ export function createLifecycle({
)
services.credits?.showOsmCredit?.(layerState._viewer, 'cctv');
layerState._lastUpdate = Date.now();
// Pick-ownership (H2): camera billboards use the camera id directly;
// Pick-ownership (H2): camera icons use the camera id directly;
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
registerPickOwner('cctv', (pickedId) => {
if (layerState._recordById.has(pickedId)) return true;
@@ -565,7 +539,7 @@ export function createLifecycle({
/**
* Tears down the layer: destroys click handler, projection loop, coverage
* entities, billboards, and clears all runtime state and subscribers.
* entities, icons, and clears all runtime state and subscribers.
* @param {Cesium.Viewer} [viewer] - Viewer instance (falls back to stored ref).
*/
destroy(viewer) {
@@ -621,10 +595,7 @@ export function createLifecycle({
parts.geometryQueue.stopGeometryLoadQueue();
parts.cards.teardownAmbientCards();
parts.geometry.destroyCoverageEntities();
if (layerState._billboards && teardownViewer) {
teardownViewer.scene.primitives.remove(layerState._billboards);
layerState._billboards = null;
}
parts.rendering.destroyCameraIcons();
clearRuntimeState();
layerState._viewer = null;
layerState._enabled = false;
+1 -1
View File
@@ -179,7 +179,7 @@ export const DEFAULT_CAMERA_CALIBRATION = Object.freeze({
heightM: 0,
});
/** Base64-encoded SVG camera icon for billboard rendering. */
/** Base64-encoded SVG camera icon for the camera icon rows. */
export const CAMERA_ICON = (() => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">
+123 -45
View File
@@ -2,6 +2,7 @@ import * as Cesium from 'cesium';
import { isHeadingEstimated } from './headingConfidence.js';
import {
ACTIVE_CAMERA_COLOR,
CAMERA_ICON,
IDLE_CAMERA_COLOR,
LIVE_CAMERA_COLOR,
ACTIVE_COVERAGE_CENTER,
@@ -14,7 +15,24 @@ import {
VIEW_ICON_MARGIN,
VIEW_MIN_CELL_M,
} from './policy.js';
import { billboardState, setBillboard } from '../../data/billboardMirror.js';
import { createIconSet } from '../../data/iconSet.js';
import { writeHoldSegment } from '../../data/iconSegment.js';
import { IconSetPrimitive } from '../../data/iconSetPrimitive.js';
import { iconAtlas } from '../../data/iconAtlas.js';
/** The camera icon's drawn size (px). */
const CAMERA_ICON_PX = 24;
/**
* Its atlas raster: 1.25× near scale on a 2× display draws it at 60 device px,
* so it is rasterised past the drawn size to stay sharp there.
*/
const CAMERA_RASTER_PX = 48;
const CAMERA_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
350,
1.25,
4_000_000,
0.42,
);
/** FNV-1a over a camera id: a stable, well-spread order for crowded cells. */
function idHash(id) {
@@ -32,6 +50,72 @@ export function createRendering({
parts,
source,
}) {
/** The scene the icon primitive was added to. */
let iconScene = null;
/**
* The catalog's icon set and the primitive that draws it. Cameras hold still,
* so each row is a hold segment and the primitive's clock only keeps segment
* times small. Rows draw depth-test-free (field-test fix 2026-07-06: the old
* finite depth-test distance re-engaged at far zoom, where the COARSE
* far-LOD Google-3D mesh sits above the true ground and swallowed
* ground-anchored icons); the GPU culls the far side of the globe and
* refreshHorizonCulling thins the rest.
* @param {Cesium.Viewer} viewer
*/
function createCameraIcons(viewer) {
// A re-init against a new viewer replaces the icons; the old ones go.
destroyCameraIcons();
const epoch = Cesium.JulianDate.now();
layerState._icons = createIconSet({ epoch });
layerState._iconPrimitive = new IconSetPrimitive({
set: layerState._icons,
renderTime: () => epoch,
requestRender: () => layerState._viewer?.scene?.requestRender?.(),
});
viewer.scene.primitives.add(layerState._iconPrimitive);
iconScene = viewer.scene;
return layerState._iconPrimitive;
}
/**
* Take the icons out of the scene they were added to, whichever viewer the
* caller tears down, and free them: a destroyed primitive left in a live
* scene throws on its next frame.
*/
function destroyCameraIcons() {
const primitive = layerState._iconPrimitive;
if (primitive && iconScene && !iconScene.isDestroyed?.())
iconScene.primitives.remove(primitive);
if (primitive && !primitive.isDestroyed()) primitive.destroy();
iconScene = null;
layerState._iconPrimitive = null;
layerState._icons = null;
}
/** Add a camera record's icon row, idle and shown, at its position. */
function addCameraIcon(record) {
const set = layerState._icons;
const id = record.camera.id;
const row = set.add(id, { layer: 'cctv', id });
const slot = iconAtlas.slot(CAMERA_ICON, CAMERA_RASTER_PX);
set.setImage(row, slot, slot);
set.setSize(row, CAMERA_ICON_PX, CAMERA_ICON_PX);
set.setScaleByDistance(row, CAMERA_SCALE_BY_DISTANCE);
set.setColor(row, cameraColorFor(record));
set.setShown(row, true);
moveCameraIcon(record);
}
/** Hold a camera's row at its record's current position. */
function moveCameraIcon(record) {
const set = layerState._icons;
const row = set?.row(record.camera.id) ?? -1;
if (row < 0 || !record.position) return;
writeHoldSegment(set.segments, row, record.position, 0);
set.segmentWritten(row);
}
/** Icon color: active wins, then live-capable video feeds, then idle stills. */
function cameraColorFor(record) {
if (record.camera.id === layerState._activeCameraId)
@@ -81,25 +165,26 @@ export function createRendering({
),
cameraDistanceFor: (position) =>
Cesium.Cartesian3.distance(camera.positionWC, position),
baseColorFor: cameraColorFor,
});
layerState._activeFocusStyleCount = result.activeCount;
}
/**
* Apply the gated CCTV focus pass through the production color path.
* Apply the gated CCTV focus pass through the set's focus column (the shader
* multiplies it into the base color's alpha).
* @param {object} input
* @param {object} [input.icons] The icon set (defaults to the layer's).
* @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}}
*/
function applyCctvFocusDeemphasis({
records,
icons = layerState._icons,
target,
previousActiveCount = 0,
nowMs,
screenPositionFor,
cameraDistanceFor,
baseColorFor,
params,
}) {
if (!focusPassIsNeeded(target, previousActiveCount)) {
@@ -109,31 +194,28 @@ export function createRendering({
let transitioning = false;
let activeCount = 0;
for (const record of records || []) {
const bb = record.billboard;
if (!bb) continue;
const icon = billboardState(bb);
const position = icon.position;
const row = icons?.row(record.camera?.id) ?? -1;
if (row < 0) continue;
const position = record.position;
// CCTV never publishes a tracked focus target, so every icon is ambient.
const focus = advanceSpriteFocus(bb, {
const focus = advanceSpriteFocus(icons.metaAt(row), {
// Keep hidden icons in the state/release pass so the active count cannot
// drop while a stale dim alpha remains waiting to reappear.
screenPosition:
icon.show === false || !position ? null : screenPositionFor(position),
!icons.shown(row) || !position ? null : screenPositionFor(position),
cameraDistance: position ? cameraDistanceFor(position) : Number.NaN,
nowMs,
target,
params,
spriteHalfWidthPx: (icon.width || 24) * (icon.scale || 1) * 0.5,
spriteHalfHeightPx: (icon.height || 24) * (icon.scale || 1) * 0.5,
spriteHalfWidthPx: icons.width(row) * icons.scale(row) * 0.5,
spriteHalfHeightPx: icons.height(row) * icons.scale(row) * 0.5,
});
transitioning ||= focus.transitioning;
if (focus.active) activeCount += 1;
const base = baseColorFor(record);
const alpha = base.alpha * focus.factor;
if (focusAlphaNeedsWrite(bb.color?.alpha, alpha, params)) {
// Order-independent narrow amendment to always-visible icons: CCTV
// contacts retain a non-zero floor while yielding near the tracked target.
bb.color = base.withAlpha(alpha);
// Order-independent narrow amendment to always-visible icons: CCTV
// contacts retain a non-zero floor while yielding near the tracked target.
if (focusAlphaNeedsWrite(icons.focus(row), focus.factor, params)) {
icons.setFocus(row, focus.factor);
writes += 1;
}
}
@@ -141,18 +223,17 @@ export function createRendering({
}
/**
* Updates visual styles (colors, widths, visibility) for all camera billboards,
* Updates visual styles (colors, widths, visibility) for all camera icons,
* coverage polylines, viewshed volumes, and projection entities based on which
* camera is active, whether the layer is enabled, and the current
* coverage-mode/projection toggle states.
*/
/**
* Field-test fix (2026-07-06): horizon-culls camera billboards, mirroring the
* flights layer's EllipsoidalOccluder pass. With the Cesium globe hidden
* (Google-3D regime) nothing writes far-side depth, and the billboards are now
* always-on-top (`disableDepthTestDistance: INFINITY` — the far-zoom submerge
* fix), so without this pass London's cluster would shine through the planet
* from a US viewpoint.
* Field-test fix (2026-07-06): horizon-culls camera icons on the CPU as the
* icon shader does on the GPU. With the Cesium globe hidden (Google-3D
* regime) nothing writes far-side depth and the icons are always-on-top, so
* the shown set (cards, geometry, the picker) must not include London's
* cluster from a US viewpoint.
*
* The same pass thins the view so a large catalog stays readable and cheap:
* icons well off screen hide, and each camera competes for a world-anchored
@@ -202,7 +283,7 @@ export function createRendering({
const shown = [];
const cells = new Map();
for (const record of layerState._records) {
const position = billboardState(record.billboard)?.position;
const position = record.position;
if (!position) continue;
const vx = position.x * inverseRadii.x - cx;
const vy = position.y * inverseRadii.y - cy;
@@ -243,12 +324,10 @@ export function createRendering({
record.viewPass = pass;
shown.push(record);
}
const icons = layerState._icons;
for (const record of layerState._records) {
const bb = record.billboard;
if (!bb) continue;
const visible = record.viewPass === pass;
if (billboardState(bb).show !== visible)
setBillboard(bb, 'show', visible);
const row = icons?.row(record.camera.id) ?? -1;
if (row >= 0) icons.setShown(row, record.viewPass === pass);
}
const previous = layerState._viewRecords;
const changed =
@@ -369,7 +448,7 @@ export function createRendering({
layerState._activeCameraId,
);
parts.projection.clearProjectionOverlay();
if (layerState._billboards) layerState._billboards.show = false;
if (layerState._icons) layerState._icons.show = false;
parts.projection.pauseInactiveProjectionFeeds(null);
}
@@ -391,19 +470,15 @@ export function createRendering({
coverageVisible,
);
}
const icons = layerState._icons;
for (const record of layerState._records) {
const isActive = record.camera.id === activeId;
if (record.billboard) {
// Unchanged writes still dirty the collection, which then rewrites
// every icon's vertices: across a large catalog, write only changes.
const color = cameraColorFor(record);
if (!Cesium.Color.equals(record.billboard.color, color))
record.billboard.color = color;
const scale = isActive ? 1.25 : 1.0;
if (billboardState(record.billboard).scale !== scale)
setBillboard(record.billboard, 'scale', scale);
// disableDepthTestDistance stays POSITIVE_INFINITY for every billboard
// (set at creation) — see the field-test far-zoom submerge fix there.
const row = icons?.row(record.camera.id) ?? -1;
if (row >= 0) {
// The set skips unchanged writes, so across a large catalog only the
// changed rows upload.
icons.setColor(row, cameraColorFor(record));
icons.setScale(row, isActive ? 1.25 : 1.0);
}
if (
@@ -506,11 +581,14 @@ export function createRendering({
}
}
if (layerState._billboards)
layerState._billboards.show = !!layerState._enabled;
if (layerState._icons) layerState._icons.show = !!layerState._enabled;
parts.projection.pauseInactiveProjectionFeeds(activeId);
}
return {
createCameraIcons,
destroyCameraIcons,
addCameraIcon,
moveCameraIcon,
refreshCctvFocusStyles,
applyCctvFocusDeemphasis,
refreshHorizonCulling,
+55 -16
View File
@@ -2,6 +2,7 @@
// Icon colors under focus de-emphasis (#1200): the focus pass fades each icon
// from the same base color the coverage refresh assigns, so a video-feed camera
// stays violet while it dims instead of falling back to the idle cyan.
// Teardown takes the icons out of the scene that holds them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRendering } from './rendering.js';
@@ -11,18 +12,22 @@ import {
LIVE_CAMERA_COLOR,
} from './policy.js';
import { isVideoFeedType } from '../../sources/cctvTypes.js';
import { mirrorBillboard } from '../../data/billboardMirror.js';
import * as Cesium from 'cesium';
import { createIconSet } from '../../data/iconSet.js';
const FOCUS_FACTOR = 0.5;
function assertFadedFrom(actual, base, label) {
assert.ok(actual, `${label}: color written`);
function assertKeepsColor(icons, id, base, label) {
const row = icons.row(id);
const actual = icons.color(row);
assert.deepEqual(
[actual.red, actual.green, actual.blue],
[base.red, base.green, base.blue],
`${label}: hue`,
[actual.red, actual.green, actual.blue, actual.alpha].map(
Cesium.Color.floatToByte,
),
[base.red, base.green, base.blue, base.alpha].map(Cesium.Color.floatToByte),
`${label}: base color kept`,
);
assert.equal(actual.alpha, base.alpha * FOCUS_FACTOR, `${label}: alpha`);
assert.equal(icons.focus(row), FOCUS_FACTOR, `${label}: focus`);
}
test('focus de-emphasis fades each icon from its own color', () => {
@@ -30,7 +35,6 @@ test('focus de-emphasis fades each icon from its own color', () => {
camera: { id, feedType },
// No position, so the pass skips screen projection; the focus stub below
// decides the factor on its own.
billboard: mirrorBillboard({ show: true }),
});
const idleVideo = record('idle-hls', 'hls');
const idleStill = record('idle-image', 'image');
@@ -42,6 +46,7 @@ test('focus de-emphasis fades each icon from its own color', () => {
_activeCameraId: activeVideo.camera.id,
_activeFocusStyleCount: 0,
_lastFocusStyleAt: -Infinity,
_icons: createIconSet(),
};
const focus = {
focusNowMs: (nowMs) => nowMs,
@@ -54,21 +59,55 @@ test('focus de-emphasis fades each icon from its own color', () => {
}),
focusAlphaNeedsWrite: () => true,
};
const { refreshCctvFocusStyles } = createRendering({
const { addCameraIcon, refreshCctvFocusStyles } = createRendering({
state,
services: { focus },
parts: { model: { isVideoFeedType } },
source: {},
});
for (const r of state._records) addCameraIcon(r);
refreshCctvFocusStyles(1000);
assertFadedFrom(idleVideo.billboard.color, LIVE_CAMERA_COLOR, 'idle video');
assertFadedFrom(idleStill.billboard.color, IDLE_CAMERA_COLOR, 'idle still');
assertFadedFrom(
activeVideo.billboard.color,
ACTIVE_CAMERA_COLOR,
'active video',
);
const icons = state._icons;
assertKeepsColor(icons, 'idle-hls', LIVE_CAMERA_COLOR, 'idle video');
assertKeepsColor(icons, 'idle-image', IDLE_CAMERA_COLOR, 'idle still');
assertKeepsColor(icons, 'active-hls', ACTIVE_CAMERA_COLOR, 'active video');
assert.equal(state._activeFocusStyleCount, 3);
});
test('icons leave the scene they were added to, whichever viewer tears down', () => {
const viewer = () => {
const held = new Set();
return {
held,
scene: {
requestRender() {},
primitives: {
add: (p) => held.add(p),
remove: (p) => held.delete(p),
},
},
};
};
const first = viewer();
const second = viewer();
const state = { _viewer: first };
const { createCameraIcons, destroyCameraIcons } = createRendering({
state,
services: { focus: {} },
parts: {},
source: {},
});
const old = createCameraIcons(first);
state._viewer = second;
const current = createCameraIcons(second);
assert.equal(first.held.has(old), false, 'a re-init takes out the old icons');
assert.equal(old.isDestroyed(), true);
destroyCameraIcons();
assert.equal(second.held.has(current), false);
assert.equal(current.isDestroyed(), true);
assert.equal(state._icons, null);
});
+4 -6
View File
@@ -207,7 +207,7 @@ export function createSelection({
}
/**
* Extracts a camera ID from a Cesium pick result by checking billboard IDs,
* Extracts a camera ID from a Cesium pick result by checking icon row IDs,
* primitive IDs, and the camera ids the layer keeps for its coverage entities.
* @param {Object|null} picked - Result from scene.pick().
* @returns {string|null} Camera ID, or null if the pick is not a CCTV entity.
@@ -237,10 +237,9 @@ export function createSelection({
if (record && (ownsCoverageEntity || ownsProjectionEntity)) return value;
}
// Camera billboard IDs are intentionally the upstream camera ID, so a
// Camera icon row 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.
// ownership proof: require that this layer's icon set drew the pick.
const directId =
typeof picked.id === 'string'
? picked.id
@@ -250,8 +249,7 @@ export function createSelection({
const record =
directId === null ? null : layerState._recordById.get(directId);
if (!record) return null;
return picked.primitive === layerState._billboards ||
picked.primitive === record.billboard
return layerState._icons && picked.iconSet === layerState._icons
? directId
: null;
}
+3 -1
View File
@@ -15,7 +15,9 @@ export function createState({ services }) {
layerState._viewer = null;
layerState._billboards = null;
/** The catalog's icon set and the primitive that draws it. */
layerState._icons = null;
layerState._iconPrimitive = null;
layerState._records = [];
+13 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { createIconSet } from '../../data/iconSet.js';
export function createTesting({ state: layerState, services, parts, source }) {
/** Test seam: republishes host entries through the real push path, so tests
@@ -101,7 +102,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
);
layerState._coverageEntities = [];
layerState._projectionEntities = [];
layerState._billboards = null;
layerState._iconPrimitive = null;
layerState._activeCameraId = activeCameraId;
layerState._autoHopSuspended = false;
layerState._enabled = !!enabled;
@@ -115,6 +116,16 @@ export function createTesting({ state: layerState, services, parts, source }) {
layerState._cameraIndexCache = null;
layerState._coverageVisibleIds = new Set();
layerState._neighborCountCache = null;
// A real set (no primitive): every record gets its row as init adds it.
layerState._icons = createIconSet();
for (const record of layerState._records)
if (record?.camera?.id) parts.rendering.addCameraIcon(record);
}
/** The layer's icon set, for tests that read what the rows draw. */
function _cctvIconsForTest() {
return layerState._icons;
}
return {
_pushAmbientCardEntriesForTest,
@@ -123,5 +134,6 @@ export function createTesting({ state: layerState, services, parts, source }) {
_updateCctvProjectionPlaneForTest,
_extractPickedCameraIdForTest,
_setCctvCoverageStateForTest,
_cctvIconsForTest,
};
}