diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d658f4290..3ebc8fdfb 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/data/cctv.js b/src/data/cctv.js index ad95f2794..389fbaab8 100644 --- a/src/data/cctv.js +++ b/src/data/cctv.js @@ -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; diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index 483dec399..db35e497b 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -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, diff --git a/src/data/cctvViewThinning.test.mjs b/src/data/cctvViewThinning.test.mjs index 62eacbab6..a7ca3d3b5 100644 --- a/src/data/cctvViewThinning.test.mjs +++ b/src/data/cctvViewThinning.test.mjs @@ -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`, + ); } }); diff --git a/src/data/focusResumeGate.test.mjs b/src/data/focusResumeGate.test.mjs index 8719bb436..5e70f1ab1 100644 --- a/src/data/focusResumeGate.test.mjs +++ b/src/data/focusResumeGate.test.mjs @@ -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, }; }); diff --git a/src/data/iconSetPrimitive.js b/src/data/iconSetPrimitive.js index 90f0900ce..1d740815f 100644 --- a/src/data/iconSetPrimitive.js +++ b/src/data/iconSetPrimitive.js @@ -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; diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js index f80650854..de8e7f5aa 100644 --- a/src/layers/cctv/geometry.js +++ b/src/layers/cctv/geometry.js @@ -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) => { diff --git a/src/layers/cctv/ground.js b/src/layers/cctv/ground.js index a7e610c4d..177ebba04 100644 --- a/src/layers/cctv/ground.js +++ b/src/layers/cctv/ground.js @@ -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; diff --git a/src/layers/cctv/index.js b/src/layers/cctv/index.js index 6f1cc77c7..dbcbcc4b2 100644 --- a/src/layers/cctv/index.js +++ b/src/layers/cctv/index.js @@ -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, diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js index 40ccf8401..5db0a9280 100644 --- a/src/layers/cctv/lifecycle.js +++ b/src/layers/cctv/lifecycle.js @@ -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--` 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; diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js index 574f87527..b442eadf3 100644 --- a/src/layers/cctv/policy.js +++ b/src/layers/cctv/policy.js @@ -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 = ` diff --git a/src/layers/cctv/rendering.js b/src/layers/cctv/rendering.js index 36ac3d8d4..8433c2e14 100644 --- a/src/layers/cctv/rendering.js +++ b/src/layers/cctv/rendering.js @@ -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, diff --git a/src/layers/cctv/rendering.test.mjs b/src/layers/cctv/rendering.test.mjs index 4ba0c2b02..408491d04 100644 --- a/src/layers/cctv/rendering.test.mjs +++ b/src/layers/cctv/rendering.test.mjs @@ -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); +}); diff --git a/src/layers/cctv/selection.js b/src/layers/cctv/selection.js index 23473258c..8b36daa0b 100644 --- a/src/layers/cctv/selection.js +++ b/src/layers/cctv/selection.js @@ -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; } diff --git a/src/layers/cctv/state.js b/src/layers/cctv/state.js index 78c2b8fa9..ab4dc6aed 100644 --- a/src/layers/cctv/state.js +++ b/src/layers/cctv/state.js @@ -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 = []; diff --git a/src/layers/cctv/testing.js b/src/layers/cctv/testing.js index c4aada9bb..d7928f280 100644 --- a/src/layers/cctv/testing.js +++ b/src/layers/cctv/testing.js @@ -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, }; }