/** * @module cctvViewshed * * Viewshed presentation for the CCTV layer (design: * docs/superpowers/specs/2026-07-05-cctv-viewshed-gizmo-design.md §3a/§3b). * * Two responsibilities, both pure of layer state: * - Color identity: a stable per-camera hue (golden-angle spaced over the * id-sorted catalog index) and the derived fill/line Cesium colors. * - Volume geometry: a translucent frustum pyramid primitive built FROM the * exact frustumCartesians positions the wireframe uses — welded by * construction, never recomputed independently. 5 vertices, 6 triangles, * synchronous build (trivially small), no picking. * * Nothing here queries the scene or persists anything: colors are * presentation-only and volumes are rebuilt only at the moments the frustum * wireframe already rewrites (pose edits / visible-set changes). */ import * as Cesium from 'cesium'; /** Golden angle in degrees — maximally spreads consecutive indices around the hue wheel. */ const GOLDEN_ANGLE_DEG = 137.50776405003785; const FILL_ALPHA_IDLE = 0.12; const FILL_ALPHA_ACTIVE = 0.22; const LINE_ALPHA_IDLE = 0.85; const LINE_ALPHA_ACTIVE = 1.0; /** * Stable hue (degrees, [0, 360)) for a camera's position in the id-sorted * catalog. Golden-angle spacing keeps any local cluster of neighbor cameras * visually separated; id-sorting makes the assignment deterministic across * sessions for a stable catalog (design §3a, owner question Q4). * @param {number} index - Camera index in the id-sorted catalog. * @returns {number} Hue in degrees. */ export function cameraHue(index) { const i = Number.isFinite(index) ? Math.max(0, Math.floor(index)) : 0; return (i * GOLDEN_ANGLE_DEG) % 360; } /** * Derived viewshed colors for a hue: translucent volume fills (idle/active) * and wireframe line tints (idle/active), all in the same hue family. * @param {number} hueDeg - Hue in degrees. * @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}} */ export function viewshedColors(hueDeg) { const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360; return { fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE), fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE), line: Cesium.Color.fromHsl(hue, 0.9, 0.65, LINE_ALPHA_IDLE), lineActive: Cesium.Color.fromHsl(hue, 0.9, 0.7, LINE_ALPHA_ACTIVE), }; } /** * Flattens frustumCartesians positions into the raw vertex/index buffers of * the frustum volume: vertex order [mount, tl, tr, br, bl]; 4 side faces from * the apex + the far cap split into 2 triangles. Pure — this is the ONLY * geometry definition (weld-by-construction with the wireframe, which draws * its rays/cap from the same 5 Cartesians). * @param {{mount: Cesium.Cartesian3, tl: Cesium.Cartesian3, tr: Cesium.Cartesian3, * br: Cesium.Cartesian3, bl: Cesium.Cartesian3}} positions - frustumCartesians shape. * @returns {{positions: Float64Array, indices: Uint16Array}} */ export function frustumVolumeGeometryData(positions) { const pts = [ positions.mount, positions.tl, positions.tr, positions.br, positions.bl, ]; const flat = new Float64Array(15); pts.forEach((p, i) => { flat[i * 3] = p.x; flat[i * 3 + 1] = p.y; flat[i * 3 + 2] = p.z; }); // apex=0, tl=1, tr=2, br=3, bl=4 — 4 side faces + far cap (2 triangles). const indices = new Uint16Array([ 0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1, 1, 2, 3, 1, 3, 4, ]); return { positions: flat, indices }; } /** * Builds the translucent frustum-volume primitive for one camera. * Synchronous (6 triangles — cheaper than the async geometry pipeline's * bookkeeping), unlit flat color, both faces visible (the viewer is routinely * inside or behind a cone), never pickable (clicks fall through to the * billboard/wireframe/plane, whose pick semantics are established). * @param {Object} positions - frustumCartesians shape (see frustumVolumeGeometryData). * @param {Cesium.Color} color - Per-camera fill color (already alpha'd). * @returns {Cesium.Primitive} */ export function createFrustumVolumePrimitive(positions, color) { const { positions: flat, indices } = frustumVolumeGeometryData(positions); const geometry = new Cesium.Geometry({ attributes: { position: new Cesium.GeometryAttribute({ componentDatatype: Cesium.ComponentDatatype.DOUBLE, componentsPerAttribute: 3, values: flat, }), }, indices, primitiveType: Cesium.PrimitiveType.TRIANGLES, boundingSphere: Cesium.BoundingSphere.fromVertices(Array.from(flat)), }); return new Cesium.Primitive({ geometryInstances: new Cesium.GeometryInstance({ geometry, attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor(color), }, }), appearance: new Cesium.PerInstanceColorAppearance({ flat: true, translucent: true, renderState: { cull: { enabled: false }, }, }), asynchronous: false, allowPicking: false, }); }