mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
The CCTV layer kept every loaded camera live: each view change touched every icon, every notification rebuilt every camera's state, and the picker listed the whole catalog. With the default 4,000 cameras, a continental drag ran at 152-179 fps with 18-24 frames over 30 ms (worst 112 ms). It now runs at 235 fps with none. The layer now: - shows icons only on or near the screen, and when zoomed out keeps one camera per world-anchored cell (healthy feeds first), so panning keeps the same cameras; - grounds and draws coverage only for the cameras in view, the active camera and its coverage neighbours, retrying a camera at most twice per view regime; - reruns the view pass while the camera moves, paced by its own cost, and projects with one matrix per pass instead of a Cesium call per camera; - keeps the billboard buffer-usage hint fixed, so showing or hiding a few icons no longer rewrites the vertex data of every billboard; - lists the cameras in view (active camera first) in the picker, closing with the count of cameras outside the view, and builds the full camera list in the UI state only when it is read.
854 lines
31 KiB
JavaScript
854 lines
31 KiB
JavaScript
import * as Cesium from 'cesium';
|
||
import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js';
|
||
import {
|
||
FRUSTUM_GROUND_CLEARANCE_M,
|
||
PROBE_MIN_RANGE_M,
|
||
PROBE_CLEARANCE_M,
|
||
COVERAGE_NEIGHBOR_RADIUS_KM,
|
||
COVERAGE_NEIGHBOR_LIMIT,
|
||
IDLE_COVERAGE_COLOR,
|
||
PLANE_FOOTPRINT_LIFT_CAP_M,
|
||
} from './policy.js';
|
||
|
||
import {
|
||
planeDimensions,
|
||
poseHash,
|
||
requiredPlaneLift,
|
||
} from '../../data/cctvFootprint.js';
|
||
|
||
export function createGeometry({ state: layerState, services, parts, source }) {
|
||
const { warmGroundFloor, cachedGroundFloor } = services.ground;
|
||
const { sampleMeshFloorCells } = services.mesh;
|
||
|
||
/**
|
||
* Ground measured under the plane's support points for the record's
|
||
* CURRENT pose, or null. Shipped precompute samples (`camera.groundHeights`)
|
||
* were taken for the nominal pose; they apply only while the pose hash still
|
||
* matches, so a calibrated (edited) camera falls back to the mount ground
|
||
* until its own footprint is resolved. Photoreal regime only: the values
|
||
* are aligned to the Google 3D Tiles surface and would be wrong against a
|
||
* globe DEM.
|
||
* @param {Object} record
|
||
* @returns {Record<string, number>|null}
|
||
*/
|
||
function footprintGroundFor(record) {
|
||
if (hasShippedFootprint(record)) {
|
||
return record.camera.groundHeights.supports || null;
|
||
}
|
||
// DEM footprint resolved on demand (activation / calibration commit) —
|
||
// valid in either regime, the DEM is what the globe stacks render anyway.
|
||
const resolved = record?.footprintGround;
|
||
if (resolved && resolved.poseHash === poseHash(footprintPose(record))) {
|
||
return resolved.supports || null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* The pose the monitor plane is actually rendered with: the calibrated
|
||
* camera fields plus the activation probe's range clamp. Footprint samples
|
||
* (shipped or DEM) are only valid for exactly this pose.
|
||
* @param {Object} record
|
||
* @returns {{lat:number, lon:number, headingDeg:number, pitchDeg:number,
|
||
* fovDeg:number, rangeM:number, mountHeightM:number}}
|
||
*/
|
||
function footprintPose(record) {
|
||
const camera = record.camera;
|
||
const override = parts.model.safeNumber(record.probeClampRangeM, NaN);
|
||
const rangeM =
|
||
Number.isFinite(override) && override > 0
|
||
? Math.min(camera.rangeM, override)
|
||
: camera.rangeM;
|
||
return {
|
||
lat: camera.lat,
|
||
lon: camera.lon,
|
||
headingDeg: camera.headingDeg,
|
||
pitchDeg: camera.pitchDeg,
|
||
fovDeg: camera.fovDeg,
|
||
rangeM,
|
||
mountHeightM: camera.mountHeightM,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* True when the record carries shipped precompute samples for its CURRENT
|
||
* pose and the scene is rendering the Google 3D Tiles surface they are
|
||
* aligned to.
|
||
* @param {Object} record
|
||
* @returns {boolean}
|
||
*/
|
||
function hasShippedFootprint(record) {
|
||
const shipped = record?.camera?.groundHeights;
|
||
if (!shipped || typeof shipped !== 'object') return false;
|
||
if (parts.ground.currentSurfaceRegime() !== 'google-3d') return false;
|
||
// Samples describe the pose as served. Any calibration, client clamp or
|
||
// activation range clamp changes the rendered pose and its hash, and the
|
||
// camera then falls back to its own DEM footprint.
|
||
return (
|
||
Number.isFinite(shipped.mountGroundM) &&
|
||
shipped.poseHash === poseHash(footprintPose(record))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* V2 core geometry (design §2a): computes the pitched frustum pyramid — mount
|
||
* point, far-cap (monitor plane) center, and the 4 far-plane corners — purely
|
||
* from the calibrated pose + a caller-supplied ground altitude. ZERO scene
|
||
* queries: ground sampling happens in the caller (one-shot snap), and the
|
||
* obstruction probe passes its clamp in as `rangeOverrideM`.
|
||
*
|
||
* Math (spherical small-angle offsets; sub-centimetre at ≤2.2 km ranges):
|
||
* mountAlt = groundAltM + mountHeightM
|
||
* capCenter = projectPoint(heading, R·cos(pitch)) @ alt mountAlt + R·sin(pitch)
|
||
* halfW = R·tan(hFov/2)
|
||
* vFov = 2·atan(tan(hFov/2) / (16/9)) → halfH = R·tan(vFov/2)
|
||
* upOffset = cos(pitch)·halfH vertical + (−sin(pitch))·halfH along heading
|
||
* corners = (capCenter ∓ halfW toward heading∓90°) ± upOffset
|
||
* The whole rectangle is then lifted rigidly by the smallest amount that
|
||
* puts every support point (a 3×3 grid over the plane) at least 2 m above
|
||
* the ground measured under it — the ground at the mount where nothing
|
||
* finer is known — so the plane never clips into the terrain and the
|
||
* wireframe rays still terminate exactly on its corners.
|
||
*
|
||
* @param {Object} camera - Pose: lat, lon, headingDeg, pitchDeg, fovDeg,
|
||
* rangeM, mountHeightM.
|
||
* @param {number} groundAltM - Ground altitude at the mount (metres).
|
||
* @param {number|null} [rangeOverrideM=null] - Obstruction-probe clamp: caps the
|
||
* effective range (never lengthens it).
|
||
* @param {Record<string, number>|null} [groundUnderPlane=null] - Ground
|
||
* altitude measured under each plane support point (`bl`…`tr`, see
|
||
* footprint.js), from the shipped precompute or a DEM lookup.
|
||
* @returns {{ rangeM: number, vFovDeg: number, halfW: number, halfH: number,
|
||
* mount: {lat:number,lon:number,alt:number},
|
||
* capCenter: {lat:number,lon:number,alt:number},
|
||
* corners: { tl: Object, tr: Object, br: Object, bl: Object },
|
||
* topCenter: {lat:number,lon:number,alt:number}, groundAltM: number }}
|
||
*/
|
||
|
||
function computeFrustumGeometry(
|
||
camera,
|
||
groundAltM,
|
||
rangeOverrideM = null,
|
||
groundUnderPlane = null,
|
||
) {
|
||
const ground = parts.model.safeNumber(groundAltM, 0);
|
||
const poseRange = Math.max(1, parts.model.safeNumber(camera.rangeM, 700));
|
||
const override = parts.model.safeNumber(rangeOverrideM, NaN);
|
||
const R =
|
||
Number.isFinite(override) && override > 0
|
||
? Math.min(poseRange, override)
|
||
: poseRange;
|
||
const pitchDeg = parts.model.clamp(
|
||
parts.model.safeNumber(camera.pitchDeg, -17),
|
||
-89,
|
||
89,
|
||
);
|
||
const fovDeg = parts.model.clamp(
|
||
parts.model.safeNumber(camera.fovDeg, 74),
|
||
8,
|
||
160,
|
||
);
|
||
const heading = parts.model.safeNumber(camera.headingDeg, 0);
|
||
const mountAlt = ground + parts.model.safeNumber(camera.mountHeightM, 24);
|
||
const dims = planeDimensions({ rangeM: R, pitchDeg, fovDeg });
|
||
|
||
const capLL = parts.model.projectPoint(
|
||
camera.lat,
|
||
camera.lon,
|
||
heading,
|
||
dims.horiz,
|
||
);
|
||
const capAlt = mountAlt + dims.vert;
|
||
const capL = parts.model.projectPoint(
|
||
capLL.lat,
|
||
capLL.lon,
|
||
heading - 90,
|
||
dims.halfW,
|
||
);
|
||
const capR = parts.model.projectPoint(
|
||
capLL.lat,
|
||
capLL.lon,
|
||
heading + 90,
|
||
dims.halfW,
|
||
);
|
||
// Ground clearance: ONE rigid lift for the whole rectangle, sized by the
|
||
// support point with the largest deficit against the ground measured
|
||
// under it (a 3×3 grid over the plane; see footprint.js). Points without
|
||
// a measurement fall back to the ground at the mount, so with no
|
||
// footprint data the bottom edge still clears the mount's ground — the
|
||
// old center-only clamp left the bottom edge tens of metres underground
|
||
// (owner field test 2026-09-13). The rectangle stays rigid and the
|
||
// wireframe rays still terminate exactly on its corners.
|
||
// Two lifts: what the mount's own ground demands (always honoured), plus
|
||
// whatever the measured footprint adds, capped so a building under the
|
||
// far edge cannot send the plane into the sky.
|
||
const base = requiredPlaneLift(
|
||
capAlt,
|
||
dims,
|
||
null,
|
||
ground,
|
||
FRUSTUM_GROUND_CLEARANCE_M,
|
||
);
|
||
const measured = requiredPlaneLift(
|
||
capAlt,
|
||
dims,
|
||
groundUnderPlane,
|
||
ground,
|
||
FRUSTUM_GROUND_CLEARANCE_M,
|
||
);
|
||
const footprintExtraM = Math.max(0, measured.liftM - base.liftM);
|
||
const liftM =
|
||
base.liftM + Math.min(PLANE_FOOTPRINT_LIFT_CAP_M, footprintExtraM);
|
||
const limitingKey =
|
||
footprintExtraM > 0 ? measured.limitingKey : base.limitingKey;
|
||
const capAltLifted = capAlt + liftM;
|
||
const corner = (base, sign) => {
|
||
const ll = parts.model.projectPoint(
|
||
base.lat,
|
||
base.lon,
|
||
heading,
|
||
sign * dims.upHoriz,
|
||
);
|
||
return {
|
||
lat: ll.lat,
|
||
lon: ll.lon,
|
||
alt: capAltLifted + sign * dims.upVert,
|
||
};
|
||
};
|
||
|
||
const topCenter = corner(capLL, 1);
|
||
return {
|
||
rangeM: R,
|
||
vFovDeg: dims.vFovDeg,
|
||
halfW: dims.halfW,
|
||
halfH: dims.halfH,
|
||
mount: { lat: camera.lat, lon: camera.lon, alt: mountAlt },
|
||
capCenter: { lat: capLL.lat, lon: capLL.lon, alt: capAltLifted },
|
||
corners: {
|
||
tl: corner(capL, 1),
|
||
tr: corner(capR, 1),
|
||
br: corner(capR, -1),
|
||
bl: corner(capL, -1),
|
||
},
|
||
topCenter,
|
||
groundAltM: ground,
|
||
liftM,
|
||
liftLimitedBy: limitingKey,
|
||
liftCapped: footprintExtraM > PLANE_FOOTPRINT_LIFT_CAP_M,
|
||
footprintMeasured: !!groundUnderPlane,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Resolves the obstruction probe's effective-range clamp just short of a hit,
|
||
* with the field-derived 12 m floor used by the original H6 monitor.
|
||
* @param {number} rangeM Nominal camera range.
|
||
* @param {number} hitDistanceM Distance to the first obstruction.
|
||
* @returns {number|null} Clamp range, or null when the hit does not shorten it.
|
||
*/
|
||
|
||
function activationProbeClampRange(rangeM, hitDistanceM) {
|
||
const nominalRange = Number(rangeM);
|
||
const hitDistance = Number(hitDistanceM);
|
||
if (!Number.isFinite(nominalRange) || nominalRange <= 0) return null;
|
||
if (
|
||
!Number.isFinite(hitDistance) ||
|
||
hitDistance <= 0 ||
|
||
hitDistance >= nominalRange
|
||
)
|
||
return null;
|
||
return Math.max(PROBE_MIN_RANGE_M, hitDistance - PROBE_CLEARANCE_M);
|
||
}
|
||
|
||
/**
|
||
* Converts a computeFrustumGeometry result into the Cartesian3 positions the
|
||
* entities consume. Fresh objects per call (geometry updates are rare —
|
||
* slider/save/activation — and entities must never share scratch objects).
|
||
* @param {Object} geometry - Result of computeFrustumGeometry.
|
||
* @returns {{ mount: Cesium.Cartesian3, capCenter: Cesium.Cartesian3,
|
||
* tl: Cesium.Cartesian3, tr: Cesium.Cartesian3, br: Cesium.Cartesian3,
|
||
* bl: Cesium.Cartesian3, label: Cesium.Cartesian3 }}
|
||
*/
|
||
|
||
function frustumCartesians(geometry) {
|
||
const at = (p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.alt);
|
||
return {
|
||
mount: at(geometry.mount),
|
||
capCenter: at(geometry.capCenter),
|
||
tl: at(geometry.corners.tl),
|
||
tr: at(geometry.corners.tr),
|
||
br: at(geometry.corners.br),
|
||
bl: at(geometry.corners.bl),
|
||
label: Cesium.Cartesian3.fromDegrees(
|
||
geometry.topCenter.lon,
|
||
geometry.topCenter.lat,
|
||
geometry.topCenter.alt + 1.2,
|
||
),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Unit ECEF direction of the frustum view axis (heading/pitch) at a position.
|
||
* Used by the plane orientation and the activation obstruction probe — both
|
||
* need the UNCLAMPED axis, so it comes from the pose, not from clamped points.
|
||
* @param {Object} camera - Camera pose (headingDeg, pitchDeg).
|
||
* @param {Cesium.Cartesian3} atPos - ECEF position defining the local ENU frame.
|
||
* @returns {{ dir: Cesium.Cartesian3, up: Cesium.Cartesian3 }} View axis + the
|
||
* frame's in-plane "up" (both unit, mutually perpendicular).
|
||
*/
|
||
|
||
function frustumFrameEcef(camera, atPos) {
|
||
const h = parts.model.toRad(camera.headingDeg);
|
||
const p = parts.model.toRad(camera.pitchDeg);
|
||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(atPos);
|
||
const rot = Cesium.Matrix4.getMatrix3(enu, new Cesium.Matrix3());
|
||
// ENU components: view axis + the perpendicular "up" of the pitched cap.
|
||
const dirEnu = new Cesium.Cartesian3(
|
||
Math.sin(h) * Math.cos(p),
|
||
Math.cos(h) * Math.cos(p),
|
||
Math.sin(p),
|
||
);
|
||
const upEnu = new Cesium.Cartesian3(
|
||
-Math.sin(p) * Math.sin(h),
|
||
-Math.sin(p) * Math.cos(h),
|
||
Math.cos(p),
|
||
);
|
||
return {
|
||
dir: Cesium.Matrix3.multiplyByVector(
|
||
rot,
|
||
dirEnu,
|
||
new Cesium.Cartesian3(),
|
||
),
|
||
up: Cesium.Matrix3.multiplyByVector(rot, upEnu, new Cesium.Cartesian3()),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Recomputes the pure frustum geometry from the camera pose + the given ground
|
||
* altitude and writes it into the scene: billboard 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.
|
||
* @param {Object} record - Camera record.
|
||
* @param {number} groundAltM - Ground altitude at the mount (metres).
|
||
*/
|
||
|
||
function applyFrustumGeometry(record, groundAltM) {
|
||
const geometry = computeFrustumGeometry(
|
||
record.camera,
|
||
groundAltM,
|
||
record.probeClampRangeM,
|
||
footprintGroundFor(record),
|
||
);
|
||
const positions = frustumCartesians(geometry);
|
||
record.frustumGeometry = geometry;
|
||
record.frustumPositions = positions;
|
||
record.position = positions.mount;
|
||
record.camera.absoluteHeightM = geometry.mount.alt;
|
||
if (record.billboard) {
|
||
record.billboard.position = positions.mount;
|
||
}
|
||
if (record.coverageEntities?.length >= 5) {
|
||
record.coverageEntities[0].polyline.positions = [
|
||
positions.mount,
|
||
positions.tl,
|
||
];
|
||
record.coverageEntities[1].polyline.positions = [
|
||
positions.mount,
|
||
positions.tr,
|
||
];
|
||
record.coverageEntities[2].polyline.positions = [
|
||
positions.mount,
|
||
positions.br,
|
||
];
|
||
record.coverageEntities[3].polyline.positions = [
|
||
positions.mount,
|
||
positions.bl,
|
||
];
|
||
record.coverageEntities[4].polyline.positions = [
|
||
positions.tl,
|
||
positions.tr,
|
||
positions.br,
|
||
positions.bl,
|
||
positions.tl,
|
||
];
|
||
}
|
||
// A live viewshed volume tracks its wireframe: rebuild from the SAME fresh
|
||
// positions (weld invariant). Only records currently showing a volume pay
|
||
// this (6 triangles, synchronous — trivial even during slider/gizmo drags).
|
||
// Tint derives from the live active id, not the cached viewshedActiveTint —
|
||
// during an activation switch this runs BEFORE refreshCoverageStyles, and
|
||
// the cache is stale for exactly that window.
|
||
if (record.viewshedPrimitive) {
|
||
rebuildViewshedVolume(
|
||
record,
|
||
record.camera.id === layerState._activeCameraId,
|
||
);
|
||
}
|
||
parts.projection.updatePlanePlacement(record);
|
||
// Gizmo handles track the pose they manipulate: refresh when the ACTIVE
|
||
// camera's geometry rewrites (incl. during its own drag).
|
||
if (
|
||
layerState._gizmo?.isEnabled() &&
|
||
record.camera.id === layerState._activeCameraId
|
||
) {
|
||
layerState._gizmo.refresh();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Refreshes a record's frustum geometry with a regime-aware, ONE-SHOT ground
|
||
* resolution (Task 5, spec §2). Per regime:
|
||
*
|
||
* - `terrain-globe` (any globe stack): `cachedGroundFloor` returns its DEM
|
||
* floor because mesh floors are regime-disabled. The exact Re:Earth prior
|
||
* remains the immediate fallback while that coarse cell warms.
|
||
* - `google-3d` (photoreal): the shared mesh-floor sampler may refine the
|
||
* DEM cell once, subject to its existing tiles-ready, distance,
|
||
* camera-height, and acceptance gates. Geometry reads only
|
||
* `cachedGroundFloor`, never a CCTV-owned point sample.
|
||
*
|
||
* v2 samples ONLY the mount — the far cap hangs in the air off mountAlt. No
|
||
* timer, no deadband: this function is called only from the staggered
|
||
* geometry queue (the enable-time drain + update()'s one-shot tiles-ready
|
||
* completion re-enqueue), from explicit pose-edit call sites, and from the
|
||
* map-stack regime-change handler.
|
||
* @param {Object} record - Camera record.
|
||
* @param {Object} [options={}]
|
||
* @param {boolean} [options.sampleGround=true] - When false, skip shared
|
||
* mesh-floor refinement and use the cached/prior ground instead.
|
||
*/
|
||
|
||
function updateRecordGeometry(record, options = {}) {
|
||
const sampleGround = options.sampleGround !== false;
|
||
const regime = parts.ground.currentSurfaceRegime();
|
||
const point = { lat: record.camera.lat, lon: record.camera.lon };
|
||
warmGroundFloor([point]);
|
||
|
||
if (regime === 'terrain-globe') {
|
||
const cachedFloor = cachedGroundFloor(point.lat, point.lon);
|
||
const ground = Number.isFinite(cachedFloor)
|
||
? cachedFloor
|
||
: parts.ground.groundPriorAltFor(record);
|
||
record.groundSamples['terrain-globe'] = ground;
|
||
record.groundResolved['terrain-globe'] = true;
|
||
applyFrustumGeometry(record, ground);
|
||
return;
|
||
}
|
||
|
||
// Photoreal regime with shipped precompute samples for exactly this pose:
|
||
// the mount ground and the footprint come from the sidecar, no scene
|
||
// query and no shared-cell write (aircraft floors stay untouched).
|
||
if (hasShippedFootprint(record)) {
|
||
const shippedGround = record.camera.groundHeights.mountGroundM;
|
||
record.groundSamples['google-3d'] = shippedGround;
|
||
record.groundResolved['google-3d'] = true;
|
||
applyFrustumGeometry(record, shippedGround);
|
||
return;
|
||
}
|
||
|
||
// Photoreal regime. Sampling is delegated to the shared coarse-cell
|
||
// sampler. It remains event-driven, one-shot per cell, and keeps its
|
||
// existing acceptance window; CCTV adds no rooftop rejection policy.
|
||
if (sampleGround && parts.model.projectionTilesReady()) {
|
||
record.groundMeshSampleRequestCount =
|
||
(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], {
|
||
excludeObjects: excludeObjects.filter(Boolean),
|
||
viewerLat: viewerCarto
|
||
? Cesium.Math.toDegrees(viewerCarto.latitude)
|
||
: undefined,
|
||
viewerLon: viewerCarto
|
||
? Cesium.Math.toDegrees(viewerCarto.longitude)
|
||
: undefined,
|
||
});
|
||
}
|
||
|
||
const cachedFloor = cachedGroundFloor(point.lat, point.lon);
|
||
const ground = Number.isFinite(cachedFloor)
|
||
? cachedFloor
|
||
: parts.ground.groundAltFor(record, 'google-3d');
|
||
applyFrustumGeometry(record, ground);
|
||
|
||
record.groundResolved['google-3d'] = Number.isFinite(cachedFloor);
|
||
if (Number.isFinite(cachedFloor)) {
|
||
record.groundSamples['google-3d'] = ground;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Determines which camera coverage overlays should be visible based on
|
||
* proximity to the active camera. Limits visibility to
|
||
* COVERAGE_NEIGHBOR_LIMIT cameras within COVERAGE_NEIGHBOR_RADIUS_KM.
|
||
* @param {Object|null} activeRecord - The active camera record.
|
||
* @returns {Set<string>} Set of visible camera IDs.
|
||
*/
|
||
|
||
function buildCoverageVisibleSet(activeRecord) {
|
||
if (!layerState._records.length) return new Set();
|
||
// Coverage emphasis is relative to a selected camera. Without one, do not
|
||
// invent an arbitrary catalog-order cohort.
|
||
if (!activeRecord) return new Set();
|
||
|
||
const ranked = layerState._records.map((record) => {
|
||
if (record === activeRecord) {
|
||
return { record, distKm: -1 };
|
||
}
|
||
return {
|
||
record,
|
||
distKm: parts.model.haversineKm(
|
||
activeRecord.camera.lat,
|
||
activeRecord.camera.lon,
|
||
record.camera.lat,
|
||
record.camera.lon,
|
||
),
|
||
};
|
||
});
|
||
|
||
ranked.sort((a, b) => a.distKm - b.distKm);
|
||
|
||
const primary = ranked.filter(
|
||
(entry) =>
|
||
entry.distKm <= COVERAGE_NEIGHBOR_RADIUS_KM || entry.distKm === -1,
|
||
);
|
||
const fallback = ranked;
|
||
const chosen = (
|
||
primary.length >= COVERAGE_NEIGHBOR_LIMIT ? primary : fallback
|
||
)
|
||
.slice(0, COVERAGE_NEIGHBOR_LIMIT)
|
||
.map((entry) => entry.record.camera.id);
|
||
return new Set(chosen);
|
||
}
|
||
|
||
/**
|
||
* Removes (and destroys) a record's viewshed volume primitive, if any.
|
||
* @param {Object} record - Camera record.
|
||
*/
|
||
|
||
function destroyViewshedVolume(record) {
|
||
if (!record) return;
|
||
if (record.viewshedPrimitive && layerState._viewer) {
|
||
layerState._viewer.scene.primitives.remove(record.viewshedPrimitive);
|
||
}
|
||
record.viewshedPrimitive = null;
|
||
}
|
||
|
||
/**
|
||
* (Re)builds a record's translucent viewshed volume from its CURRENT
|
||
* frustumPositions — the same 5 Cartesians the wireframe draws, so the volume
|
||
* is welded to the cone by construction. Called only where the wireframe
|
||
* already rewrites (style refresh on mode/visible-set/active changes,
|
||
* applyFrustumGeometry on pose edits) — no new update cadence, zero scene
|
||
* queries (viewshed design §3b).
|
||
* @param {Object} record - Camera record.
|
||
* @param {boolean} isActive - Active camera gets the brighter fill.
|
||
*/
|
||
|
||
function rebuildViewshedVolume(record, isActive) {
|
||
destroyViewshedVolume(record);
|
||
if (
|
||
!layerState._viewer ||
|
||
!record?.frustumPositions ||
|
||
!record.viewshedColors
|
||
)
|
||
return;
|
||
const color = isActive
|
||
? record.viewshedColors.fillActive
|
||
: record.viewshedColors.fill;
|
||
const primitive = createFrustumVolumePrimitive(
|
||
record.frustumPositions,
|
||
color,
|
||
);
|
||
// QA tag: the harness counts viewshed volumes by this marker.
|
||
primitive._gevViewshed = record.camera.id;
|
||
record.viewshedPrimitive =
|
||
layerState._viewer.scene.primitives.add(primitive);
|
||
record.viewshedActiveTint = !!isActive;
|
||
}
|
||
|
||
/**
|
||
* Counts how many other cameras have overlapping coverage with the target.
|
||
* Overlap is approximated by comparing inter-camera distance against the
|
||
* combined range of both cameras (scaled by 0.92).
|
||
* @param {Object} targetRecord - Camera record to check.
|
||
* @returns {number} Number of overlapping neighbors.
|
||
*/
|
||
|
||
function coverageNeighborCount(targetRecord) {
|
||
if (!targetRecord) return 0;
|
||
// The summary asks on every notification; scan the catalog only when the
|
||
// camera, its pose or the catalog changed.
|
||
const { id, lat, lon, rangeM } = targetRecord.camera;
|
||
const key = `${id}|${lat}|${lon}|${rangeM}|${layerState._records.length}`;
|
||
const cached = layerState._neighborCountCache;
|
||
if (cached?.key === key && cached.records === layerState._records)
|
||
return cached.count;
|
||
let count = 0;
|
||
for (const record of layerState._records) {
|
||
if (record === targetRecord) continue;
|
||
const dKm = parts.model.haversineKm(
|
||
targetRecord.camera.lat,
|
||
targetRecord.camera.lon,
|
||
record.camera.lat,
|
||
record.camera.lon,
|
||
);
|
||
const overlapKm =
|
||
((targetRecord.camera.rangeM + record.camera.rangeM) / 1000) * 0.92;
|
||
if (dKm <= overlapKm) count++;
|
||
}
|
||
layerState._neighborCountCache = {
|
||
key,
|
||
records: layerState._records,
|
||
count,
|
||
};
|
||
return count;
|
||
}
|
||
|
||
/**
|
||
* §9.1 activation obstruction probe (LOCKED owner decision): on camera
|
||
* ACTIVATION only, fire ONE scene.pickFromRay along the frustum axis
|
||
* (mount → cap-center direction). If it hits the tiles closer than the pose
|
||
* range, clamp the plane's effective range just short of the first hit so the
|
||
* "big and dramatic" true end cap never clips into downtown buildings.
|
||
*
|
||
* This is the ONLY raycast in the whole CCTV subsystem — once per activation,
|
||
* never per-frame (the zero-raycast invariant applies to steady state). The
|
||
* per-camera range slider overrides the clamp: a user-set rangeScale skips the
|
||
* probe entirely. Probe failure/miss keeps the unclamped range.
|
||
* @param {Object} record - Camera record being activated.
|
||
*/
|
||
|
||
function runActivationObstructionProbe(record) {
|
||
record.probeClampRangeM = null;
|
||
const scene = layerState._viewer?.scene;
|
||
if (!scene || typeof scene.pickFromRay !== 'function') return;
|
||
const camera = record.camera;
|
||
const rangeScale = parts.calibration.normalizeCalibration(
|
||
camera.calibration,
|
||
).rangeScale;
|
||
if (Math.abs(rangeScale - 1) > 0.0001) return; // slider overrides the clamp
|
||
try {
|
||
const mountAlt = parts.ground.groundAltFor(record) + camera.mountHeightM;
|
||
const mountPos = Cesium.Cartesian3.fromDegrees(
|
||
camera.lon,
|
||
camera.lat,
|
||
mountAlt,
|
||
);
|
||
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];
|
||
for (const runtime of layerState._projectionEntities) {
|
||
if (runtime?.planeEntity) exclude.push(runtime.planeEntity);
|
||
}
|
||
const hit = scene.pickFromRay(new Cesium.Ray(mountPos, dir), exclude);
|
||
if (!hit?.position) return;
|
||
const dist = Cesium.Cartesian3.distance(mountPos, hit.position);
|
||
record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist);
|
||
} catch {
|
||
// probe failure → keep the unclamped range
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clears a deactivated camera's temporary obstruction clamp and rewrites its
|
||
* geometry through the normal single-range path.
|
||
* @param {Object|null} record Camera runtime record being deactivated.
|
||
* @param {(record: Object) => void} rewriteGeometry Nominal geometry rewrite.
|
||
* @returns {boolean} Whether a clamp was cleared.
|
||
*/
|
||
|
||
function clearProbeClampOnDeactivation(record, rewriteGeometry) {
|
||
if (!record || !Number.isFinite(record.probeClampRangeM)) return false;
|
||
record.probeClampRangeM = null;
|
||
rewriteGeometry?.(record);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Creates the five Cesium polyline entities that visualize a camera's pitched
|
||
* frustum: 4 corner rays (mount → far-plane corner) + the closed far-plane
|
||
* rectangle. Entity ids stay in the `cctv-<id>-<role>` scheme (pick-owner
|
||
* regex depends on it): roles ray-tl / ray-tr / ray-br / ray-bl / cap.
|
||
* @param {Object} record - Camera record.
|
||
* @returns {Cesium.Entity[]} Array of five coverage entities.
|
||
*/
|
||
|
||
function buildCoverageEntities(record) {
|
||
const { camera } = record;
|
||
// Prefer the record's already-refined geometry. Lazy creation commonly
|
||
// happens after the staggered ground pass; recomputing from the catalog
|
||
// prior here would regress the camera to its pre-sampled datum.
|
||
let geometry = record.frustumGeometry;
|
||
let positions = record.frustumPositions;
|
||
if (!geometry || !positions) {
|
||
geometry = computeFrustumGeometry(
|
||
camera,
|
||
parts.ground.groundPriorAltFor(record),
|
||
record.probeClampRangeM,
|
||
);
|
||
positions = frustumCartesians(geometry);
|
||
record.frustumGeometry = geometry;
|
||
record.frustumPositions = positions;
|
||
record.position = positions.mount;
|
||
}
|
||
|
||
const addPolyline = (role, linePositions) =>
|
||
layerState._viewer.entities.add({
|
||
id: `cctv-${camera.id}-${role}`,
|
||
properties: { cctvCameraId: camera.id },
|
||
polyline: {
|
||
positions: linePositions,
|
||
width: 1.2,
|
||
material: IDLE_COVERAGE_COLOR,
|
||
},
|
||
});
|
||
|
||
const entities = [
|
||
addPolyline('ray-tl', [positions.mount, positions.tl]),
|
||
addPolyline('ray-tr', [positions.mount, positions.tr]),
|
||
addPolyline('ray-br', [positions.mount, positions.br]),
|
||
addPolyline('ray-bl', [positions.mount, positions.bl]),
|
||
addPolyline('cap', [
|
||
positions.tl,
|
||
positions.tr,
|
||
positions.br,
|
||
positions.bl,
|
||
positions.tl,
|
||
]),
|
||
];
|
||
|
||
entities[0]._coverageRole = 'edge';
|
||
entities[1]._coverageRole = 'edge';
|
||
entities[2]._coverageRole = 'edge';
|
||
entities[3]._coverageRole = 'edge';
|
||
entities[4]._coverageRole = 'cap';
|
||
return entities;
|
||
}
|
||
|
||
/**
|
||
* Materializes coverage entities for eligible records exactly once.
|
||
* The helper is dependency-injected so unit tests can prove the enable policy
|
||
* without constructing Cesium entities.
|
||
*
|
||
* @param {Object[]} records CCTV runtime records.
|
||
* @param {(record: Object) => boolean} [isEligible] Eligibility predicate.
|
||
* @param {(record: Object) => Object[]} buildEntities Coverage builder.
|
||
* @returns {Object[]} Newly created entities across all eligible records.
|
||
*/
|
||
|
||
function materializeCctvCoverageEntities(
|
||
records,
|
||
isEligible = () => true,
|
||
buildEntities,
|
||
) {
|
||
const created = [];
|
||
if (typeof buildEntities !== 'function') return created;
|
||
for (const record of Array.isArray(records) ? records : []) {
|
||
if (!record || record.coverageEntities?.length || !isEligible(record))
|
||
continue;
|
||
const entities = buildEntities(record);
|
||
record.coverageEntities = Array.isArray(entities)
|
||
? entities.filter(Boolean)
|
||
: [];
|
||
created.push(...record.coverageEntities);
|
||
}
|
||
return created;
|
||
}
|
||
|
||
/** Materializes one active camera's coverage set. */
|
||
|
||
function materializeCctvActiveCoverageEntities(record, buildEntities) {
|
||
return materializeCctvCoverageEntities([record], () => true, buildEntities);
|
||
}
|
||
|
||
/** Materializes only records in the current coverage-visible ID set. */
|
||
|
||
function materializeCctvVisibleCoverageEntities(
|
||
records,
|
||
visibleIds,
|
||
buildEntities,
|
||
) {
|
||
const eligibleIds =
|
||
visibleIds instanceof Set ? visibleIds : new Set(visibleIds || []);
|
||
return materializeCctvCoverageEntities(
|
||
records,
|
||
(record) => eligibleIds.has(record.camera?.id),
|
||
buildEntities,
|
||
);
|
||
}
|
||
|
||
/** Registers newly built coverage entities with the layer-global collection. */
|
||
|
||
function registerCoverageEntities(created) {
|
||
layerState._coverageEntities.push(...created);
|
||
return created;
|
||
}
|
||
|
||
function ensureActiveCoverageEntities(record) {
|
||
return registerCoverageEntities(
|
||
materializeCctvActiveCoverageEntities(record, buildCoverageEntities),
|
||
);
|
||
}
|
||
|
||
function ensureVisibleCoverageEntities(records, visibleIds) {
|
||
return registerCoverageEntities(
|
||
materializeCctvVisibleCoverageEntities(
|
||
records,
|
||
visibleIds,
|
||
buildCoverageEntities,
|
||
),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Removes all coverage entities and projection runtimes from the scene.
|
||
*/
|
||
|
||
function destroyCoverageEntities() {
|
||
if (!layerState._viewer) return;
|
||
for (const entity of layerState._coverageEntities) {
|
||
layerState._viewer.entities.remove(entity);
|
||
}
|
||
layerState._coverageEntities = [];
|
||
|
||
for (const record of layerState._records) {
|
||
destroyViewshedVolume(record);
|
||
}
|
||
|
||
for (const runtime of layerState._projectionEntities) {
|
||
parts.projection.destroyProjectionRuntime(runtime);
|
||
}
|
||
layerState._projectionEntities = [];
|
||
}
|
||
return {
|
||
computeFrustumGeometry,
|
||
hasShippedFootprint,
|
||
footprintPose,
|
||
activationProbeClampRange,
|
||
frustumCartesians,
|
||
frustumFrameEcef,
|
||
applyFrustumGeometry,
|
||
updateRecordGeometry,
|
||
buildCoverageVisibleSet,
|
||
destroyViewshedVolume,
|
||
rebuildViewshedVolume,
|
||
coverageNeighborCount,
|
||
runActivationObstructionProbe,
|
||
clearProbeClampOnDeactivation,
|
||
buildCoverageEntities,
|
||
materializeCctvCoverageEntities,
|
||
materializeCctvActiveCoverageEntities,
|
||
materializeCctvVisibleCoverageEntities,
|
||
registerCoverageEntities,
|
||
ensureActiveCoverageEntities,
|
||
ensureVisibleCoverageEntities,
|
||
destroyCoverageEntities,
|
||
};
|
||
}
|