refactor(cctv): separate camera source and scene components

This commit is contained in:
Sameh Khamis committed 2026-09-13 15:02:32 -07:00
1 parent ca5f5fff93
commit 519bc6c362
32 files changed
+6873 -4835

No files matched your search

+719
View File
@@ -0,0 +1,719 @@
import * as Cesium from 'cesium';
import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js';
import {
PROJECTION_VERT_ASPECT,
FRUSTUM_GROUND_CLEARANCE_M,
PROBE_MIN_RANGE_M,
PROBE_CLEARANCE_M,
COVERAGE_NEIGHBOR_RADIUS_KM,
COVERAGE_NEIGHBOR_LIMIT,
IDLE_COVERAGE_COLOR,
} from './policy.js';
export function createGeometry({ state: layerState, services, parts, source }) {
const { warmGroundFloor, cachedGroundFloor } = services.ground;
const { sampleMeshFloorCells } = services.mesh;
/**
* 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 cap CENTER altitude clamps to ≥ groundAltM + 2 m (§6 risk: fabricated
* pitch must never bury the plane's anchor); corners derive rigidly from the
* clamped center so the wireframe rays always terminate on the plane's
* corners — the bottom pair may dip below ground (tiles occlude it).
*
* @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).
* @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) {
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 pitch = parts.model.toRad(
parts.model.clamp(parts.model.safeNumber(camera.pitchDeg, -17), -89, 89),
);
const hFov = parts.model.toRad(
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 horiz = R * Math.cos(pitch);
const vert = R * Math.sin(pitch);
const capLL = parts.model.projectPoint(
camera.lat,
camera.lon,
heading,
horiz,
);
const capAlt = mountAlt + vert;
const halfW = R * Math.tan(hFov / 2);
const vFovRad = 2 * Math.atan(Math.tan(hFov / 2) / PROJECTION_VERT_ASPECT);
const halfH = R * Math.tan(vFovRad / 2);
// In-plane "up" of the pitched cap, decomposed into a vertical part and a
// horizontal part along the heading (pitch < 0 tilts the cap's top forward).
const upVert = Math.cos(pitch) * halfH;
const upHoriz = -Math.sin(pitch) * halfH;
const capL = parts.model.projectPoint(
capLL.lat,
capLL.lon,
heading - 90,
halfW,
);
const capR = parts.model.projectPoint(
capLL.lat,
capLL.lon,
heading + 90,
halfW,
);
// Ground clamp (§6 risk): lift the CAP CENTER once so a fabricated pitch
// never buries the plane's anchor — then derive the corners RIGIDLY from the
// lifted center. Clamping each corner independently flattened the wireframe
// into a ground-hugging fan while the rigid plane kept its height (owner
// field test 2026-07-04): the corner rays must always terminate exactly on
// the monitor plane's corners. The bottom pair may dip below ground; the 3D
// tiles occlude that portion, exactly as they do for the plane itself.
const minAlt = ground + FRUSTUM_GROUND_CLEARANCE_M;
const capAltClamped = Math.max(minAlt, capAlt);
const corner = (base, sign) => {
const ll = parts.model.projectPoint(
base.lat,
base.lon,
heading,
sign * upHoriz,
);
return { lat: ll.lat, lon: ll.lon, alt: capAltClamped + sign * upVert };
};
const topCenter = corner(capLL, 1);
return {
rangeM: R,
vFovDeg: Cesium.Math.toDegrees(vFovRad),
halfW,
halfH,
mount: { lat: camera.lat, lon: camera.lon, alt: mountAlt },
capCenter: { lat: capLL.lat, lon: capLL.lon, alt: capAltClamped },
corners: {
tl: corner(capL, 1),
tr: corner(capR, 1),
br: corner(capR, -1),
bl: corner(capL, -1),
},
topCenter,
groundAltM: ground,
};
}
/**
* 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,
);
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. 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;
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++;
}
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,
activationProbeClampRange,
frustumCartesians,
frustumFrameEcef,
applyFrustumGeometry,
updateRecordGeometry,
buildCoverageVisibleSet,
destroyViewshedVolume,
rebuildViewshedVolume,
coverageNeighborCount,
runActivationObstructionProbe,
clearProbeClampOnDeactivation,
buildCoverageEntities,
materializeCctvCoverageEntities,
materializeCctvActiveCoverageEntities,
materializeCctvVisibleCoverageEntities,
registerCoverageEntities,
ensureActiveCoverageEntities,
ensureVisibleCoverageEntities,
destroyCoverageEntities,
};
}