mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor(cctv): separate camera source and scene components
This commit is contained in:
1 parent
ca5f5fff93
commit
519bc6c362
32 files changed
+6873
-4835
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user