mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-10 13:22:18 +00:00
Draw CCTV cameras from a GPU icon set
This commit is contained in:
1 parent
89b07fddfd
commit
00d1bee127
16 files changed
+291
-168
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user