/** * @module cctvGizmo * * Direct-manipulation calibration gizmo for the CCTV layer (design: * docs/superpowers/specs/2026-07-05-cctv-viewshed-gizmo-design.md §3c). * * Two layers: * - Pure drag math (this top section): ray↔axis closest-point, ray↔plane * intersection with a grazing-angle guard, ring angles. All unit-tested * under node:test with zero scene dependencies. * - createCalibrationGizmo (below): the entity + ScreenSpaceEventHandler * controller that renders grab-able handles on the ACTIVE camera and turns * drags into calibration patches through a narrow callback interface. It * never touches the layer's records/stores directly. * * Every one of the 7 calibration DOF maps to a handle: * heading ring (yaw, local-up plane) · pitch ring (around local right) · * East/North/Up arrows (offsets + mount height) · range handle at the * far-cap center (monitor-plane distance) · FOV handles at the cap's * left/right edge midpoints (plane size). * * Scene interaction is strictly event-driven (single pick on LEFT_DOWN, * throttled hover picks while ADJUST mode is on) — the layer's zero-steady-state-query * invariant is untouched. */ import * as Cesium from 'cesium'; import { isPointerFree } from './inputOwnership.js'; // Grazing guard (spec §5): reject plane intersections when the view ray is // nearly parallel to the constraint plane — the hit point races to infinity // and a 1 px mouse move would slam the value. const GRAZING_DOT_MIN = 0.08; // Near-parallel guard for ray/axis closest-point (denominator 1 - (d·a)²). const PARALLEL_EPS = 1e-6; const scratchW = new Cesium.Cartesian3(); /** * Parameter t (metres) along an axis line of the point on that axis closest * to a mouse ray. Both directions must be normalized. * * Minimizing |(rayOrigin + s·rayDir) − (axisOrigin + t·axisDir)|² gives * t = (e − b·d) / (1 − b²) with b = rayDir·axisDir, w = rayOrigin − axisOrigin, * d = rayDir·w, e = axisDir·w. * * @param {Cesium.Cartesian3} rayOrigin * @param {Cesium.Cartesian3} rayDir - Unit. * @param {Cesium.Cartesian3} axisOrigin * @param {Cesium.Cartesian3} axisDir - Unit. * @returns {number|null} Axis parameter in metres, or null when near-parallel. */ export function closestParamOnAxis(rayOrigin, rayDir, axisOrigin, axisDir) { const b = Cesium.Cartesian3.dot(rayDir, axisDir); const denom = 1 - b * b; if (Math.abs(denom) < PARALLEL_EPS) return null; const w = Cesium.Cartesian3.subtract(rayOrigin, axisOrigin, scratchW); const d = Cesium.Cartesian3.dot(rayDir, w); const e = Cesium.Cartesian3.dot(axisDir, w); return (e - b * d) / denom; } /** * Intersects a mouse ray with a plane, refusing grazing configurations * (|rayDir·normal| < 0.08) and hits behind the ray origin. * @param {Cesium.Cartesian3} rayOrigin * @param {Cesium.Cartesian3} rayDir - Unit. * @param {Cesium.Cartesian3} planeOrigin * @param {Cesium.Cartesian3} planeNormal - Unit. * @returns {Cesium.Cartesian3|null} Hit point (new instance), or null. */ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) { const denom = Cesium.Cartesian3.dot(rayDir, planeNormal); if (Math.abs(denom) < GRAZING_DOT_MIN) return null; const toPlane = Cesium.Cartesian3.subtract(planeOrigin, rayOrigin, scratchW); const s = Cesium.Cartesian3.dot(toPlane, planeNormal) / denom; if (s < 0) return null; const hit = Cesium.Cartesian3.multiplyByScalar( rayDir, s, new Cesium.Cartesian3(), ); return Cesium.Cartesian3.add(rayOrigin, hit, hit); } /** * Angle (radians, atan2 convention) of a point around a ring center in the * plane spanned by two orthonormal basis vectors. * @param {Cesium.Cartesian3} hitPoint - Point on/near the ring plane. * @param {Cesium.Cartesian3} center - Ring center. * @param {Cesium.Cartesian3} basisA - In-plane unit vector (angle 0). * @param {Cesium.Cartesian3} basisB - In-plane unit vector (angle +90°). * @returns {number} Angle in (−π, π]. */ export function ringAngle(hitPoint, center, basisA, basisB) { const v = Cesium.Cartesian3.subtract(hitPoint, center, scratchW); return Math.atan2( Cesium.Cartesian3.dot(v, basisB), Cesium.Cartesian3.dot(v, basisA), ); } /** * Shortest signed angular delta from → to, wrap-safe. * @param {number} fromRad * @param {number} toRad * @returns {number} Delta in (−π, π]. */ export function signedAngleDelta(fromRad, toRad) { const twoPi = 2 * Math.PI; let delta = (toRad - fromRad) % twoPi; if (delta > Math.PI) delta -= twoPi; if (delta <= -Math.PI) delta += twoPi; return delta; } // --------------------------------------------------------------------------- // Gizmo controller // --------------------------------------------------------------------------- /** Entity id prefix for every gizmo part — the layer's pick-owner regex and * click-to-select guard key off this. Gizmo entities NEVER carry a * `cctvCameraId` property, so the layer's selection path ignores them. */ export const GIZMO_ID_PREFIX = 'cctv-gizmo-'; const RING_SEGMENTS = 96; const RING_RADIUS_FACTOR = 0.12; const RING_RADIUS_MIN_M = 6; const RING_RADIUS_MAX_M = 40; const ARROW_LENGTH_FACTOR = 1.6; const DRAG_THROTTLE_MS = 16; const HOVER_THROTTLE_MS = 120; const COLOR_RING_HEADING = Cesium.Color.fromCssColorString('#35d8ff'); const COLOR_RING_PITCH = Cesium.Color.fromCssColorString('#ff5fd0'); const COLOR_MOVE_EAST = Cesium.Color.fromCssColorString('#ff5252'); const COLOR_MOVE_NORTH = Cesium.Color.fromCssColorString('#52ff7a'); const COLOR_MOVE_UP = Cesium.Color.fromCssColorString('#5b8cff'); const COLOR_HANDLE = Cesium.Color.fromCssColorString('#ffd97a'); const toRadians = (deg) => (deg * Math.PI) / 180; const toDeg = (rad) => (rad * 180) / Math.PI; /** * Local ENU unit axes at an ECEF position. * @param {Cesium.Cartesian3} position * @returns {{east: Cesium.Cartesian3, north: Cesium.Cartesian3, up: Cesium.Cartesian3}} */ function enuAxes(position) { const frame = Cesium.Transforms.eastNorthUpToFixedFrame(position); const rot = Cesium.Matrix4.getMatrix3(frame, new Cesium.Matrix3()); return { east: Cesium.Matrix3.getColumn(rot, 0, new Cesium.Cartesian3()), north: Cesium.Matrix3.getColumn(rot, 1, new Cesium.Cartesian3()), up: Cesium.Matrix3.getColumn(rot, 2, new Cesium.Cartesian3()), }; } /** a*sa + b*sb (fresh Cartesian3). */ function combine2(a, sa, b, sb) { const out = Cesium.Cartesian3.multiplyByScalar( a, sa, new Cesium.Cartesian3(), ); const t = Cesium.Cartesian3.multiplyByScalar(b, sb, new Cesium.Cartesian3()); return Cesium.Cartesian3.add(out, t, out); } /** * View-frame unit vectors for a pose within a local ENU frame: horizontal * forward, viewer-right, and the full pitched view axis. * @param {number} headingDeg - Compass heading (0 = north, +east). * @param {number} pitchDeg - Elevation (+up). * @param {{east, north, up}} axes - ENU axes at the mount. */ function viewAxesFor(headingDeg, pitchDeg, axes) { const h = toRadians(headingDeg); const p = toRadians(pitchDeg); const forwardHoriz = combine2( axes.east, Math.sin(h), axes.north, Math.cos(h), ); const right = combine2(axes.east, Math.cos(h), axes.north, -Math.sin(h)); const view = combine2(forwardHoriz, Math.cos(p), axes.up, Math.sin(p)); return { forwardHoriz, right, view }; } /** Circle polyline positions around `center` in the (basisA, basisB) plane. */ function ringPositions(center, radius, basisA, basisB) { const positions = []; for (let i = 0; i <= RING_SEGMENTS; i++) { const angle = (i / RING_SEGMENTS) * 2 * Math.PI; const offset = combine2( basisA, Math.cos(angle) * radius, basisB, Math.sin(angle) * radius, ); positions.push(Cesium.Cartesian3.add(center, offset, offset)); } return positions; } /** * Creates the calibration gizmo controller (design §3c). Renders grab-able * handles for the ACTIVE camera's 7 calibration DOF and turns pointer drags * into calibration patches through a narrow callback interface — the gizmo * never touches layer records/stores directly. * * @param {Object} deps * @param {Cesium.Viewer} deps.viewer * @param {function(): Object|null} deps.getActiveRecord - Returns the record * the gizmo should attach to, or null to hide (layer decides: enabled + * calibration mode + active camera). * @param {function(Object, Object): void} deps.applyPatch - Transient * per-mousemove calibration patch (partial 7-field object, absolute offset * values) + the PINNED drag record it applies to. * @param {function(Object): void} deps.endPatch - Commit-grade tail on drag * end, for the PINNED drag record. * @returns {{setEnabled: function(boolean): void, refresh: function(): void, * destroy: function(): void, isDragging: function(): boolean, * isEnabled: function(): boolean}} */ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, endPatch, }) { let enabled = false; let drag = null; // { part, startCal, basePose, refs... } let hoveredId = null; let lastDragAt = 0; let lastHoverAt = 0; const entities = new Map(); // part -> Entity const scene = viewer.scene; const handler = new Cesium.ScreenSpaceEventHandler(scene.canvas); /** Adds (or re-adds) a gizmo entity, clearing any stale duplicate id. */ function addEntity(part, options) { const id = `${GIZMO_ID_PREFIX}${part}`; const stale = viewer.entities.getById(id); if (stale) viewer.entities.remove(stale); const entity = viewer.entities.add({ id, show: false, ...options }); entities.set(part, entity); return entity; } function polylinePart(part, color, width, arrow = false) { addEntity(part, { ...(arrow ? { position: Cesium.Cartesian3.ZERO, point: { pixelSize: 14, color, outlineColor: Cesium.Color.BLACK.withAlpha(0.7), outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY, }, } : {}), polyline: { positions: [], width, material: arrow ? new Cesium.PolylineArrowMaterialProperty(color) : color.withAlpha(0.9), // Standard gizmo convention: parts behind geometry stay clearly // visible, just dimmed (a street-level mount buries half the heading // ring in sloped photogrammetry tiles — smoke-tested 2026-07-05). depthFailMaterial: color.withAlpha(0.45), }, }); } function pointPart(part, color, pixelSize) { addEntity(part, { position: Cesium.Cartesian3.ZERO, point: { pixelSize, color, outlineColor: Cesium.Color.BLACK.withAlpha(0.7), outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY, }, }); } polylinePart('ring-heading', COLOR_RING_HEADING, 3); polylinePart('ring-pitch', COLOR_RING_PITCH, 3); polylinePart('move-east', COLOR_MOVE_EAST, 12, true); polylinePart('move-north', COLOR_MOVE_NORTH, 12, true); polylinePart('move-up', COLOR_MOVE_UP, 12, true); pointPart('handle-range', COLOR_HANDLE, 13); pointPart('handle-fov-l', COLOR_HANDLE, 10); pointPart('handle-fov-r', COLOR_HANDLE, 10); function hideAll() { for (const entity of entities.values()) entity.show = false; } /** Recomputes every handle's geometry from the active record's CURRENT pose * (the PINNED record while a drag is live, so handles never jump to another * camera mid-interaction). */ function refresh() { const record = enabled ? (drag ? drag.record : getActiveRecord()) : null; const positions = record?.frustumPositions; const camera = record?.camera; if (!record || !positions || !camera) { hideAll(); return; } const mount = positions.mount; const axes = enuAxes(mount); const { forwardHoriz } = viewAxesFor( camera.headingDeg, camera.pitchDeg, axes, ); const rangeM = Math.max(1, Number(camera.rangeM) || 1); const radius = Math.min( RING_RADIUS_MAX_M, Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR), ); const arrowLen = radius * ARROW_LENGTH_FACTOR; entities.get('ring-heading').polyline.positions = ringPositions( mount, radius, axes.north, axes.east, ); entities.get('ring-pitch').polyline.positions = ringPositions( mount, radius, forwardHoriz, axes.up, ); const arrowTo = (dir) => [ mount, Cesium.Cartesian3.add( mount, Cesium.Cartesian3.multiplyByScalar( dir, arrowLen, new Cesium.Cartesian3(), ), new Cesium.Cartesian3(), ), ]; const eastArrow = arrowTo(axes.east); const northArrow = arrowTo(axes.north); const upArrow = arrowTo(axes.up); entities.get('move-east').polyline.positions = eastArrow; entities.get('move-east').position = eastArrow[1]; entities.get('move-north').polyline.positions = northArrow; entities.get('move-north').position = northArrow[1]; entities.get('move-up').polyline.positions = upArrow; entities.get('move-up').position = upArrow[1]; entities.get('handle-range').position = positions.capCenter; entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint( positions.tl, positions.bl, new Cesium.Cartesian3(), ); entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint( positions.tr, positions.br, new Cesium.Cartesian3(), ); for (const entity of entities.values()) entity.show = true; } /** Extracts a gizmo part name from a pick result, or null. */ function gizmoPartFrom(picked) { const id = picked?.id?.id ?? picked?.id; if (typeof id !== 'string' || !id.startsWith(GIZMO_ID_PREFIX)) return null; return id.slice(GIZMO_ID_PREFIX.length); } function pickGizmoPart(windowPosition) { // Gizmo primitives render depth-test-free and should be the topmost pick. // Take the cheap single-result path first: a full drillPick can stall for // tens of seconds under software GL even when its first result is already // the gizmo. Retain drillPick for uncommon overlap/fallback cases. try { const picked = scene.pick(windowPosition, 14, 14); const part = gizmoPartFrom(picked); if (part && entities.get(part)?.show) return part; } catch (err) { if (typeof window !== 'undefined' && window.__gevGizmoDebug) { console.debug('[CCTV:gizmo] pick threw:', err?.message || err); } } let results = []; try { results = scene.drillPick(windowPosition, 6, 14, 14) || []; } catch (err) { if (typeof window !== 'undefined' && window.__gevGizmoDebug) { console.debug('[CCTV:gizmo] drillPick threw:', err?.message || err); } return null; } if (typeof window !== 'undefined' && window.__gevGizmoDebug) { console.debug( '[CCTV:gizmo] drillPick @', windowPosition?.x, windowPosition?.y, '→', JSON.stringify( results.map((r) => String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name), ), ), ); } for (const picked of results) { const part = gizmoPartFrom(picked); if (part && entities.get(part)?.show) return part; } return null; } function pickRay(windowPosition) { try { return viewer.camera.getPickRay(windowPosition) || null; } catch { return null; } } function setCursor(value) { if (scene.canvas?.style) scene.canvas.style.cursor = value; } function setHovered(part) { const nextId = part ? `${GIZMO_ID_PREFIX}${part}` : null; if (nextId === hoveredId) return; hoveredId = nextId; // Hover feedback must NEVER touch polyline geometry: a width change makes // Cesium rebuild the batched polyline primitive, and until the next render // the part vanishes from the pick buffer — so the very hover that finds a // ring makes the following LEFT_DOWN miss it (root-caused via the QA // harness's synthetic drag, 2026-07-05). Rings/arrows get cursor feedback // only; point handles are PointPrimitives (live-updatable, no rebuild), so // they keep the size bump. for (const [name, entity] of entities.entries()) { if (!entity.point) continue; const hot = hoveredId === `${GIZMO_ID_PREFIX}${name}`; entity.point.pixelSize = (name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0); } setCursor(hoveredId ? 'grab' : ''); } /** Captures the fixed drag reference frame + start values for a part. */ function beginDrag(part, windowPosition) { const record = getActiveRecord(); const positions = record?.frustumPositions; const camera = record?.camera; const geometry = record?.frustumGeometry; if (!record || !positions || !camera?.basePose || !geometry) return false; const ray = pickRay(windowPosition); if (!ray) return false; const mount = Cesium.Cartesian3.clone(positions.mount); const capCenter = Cesium.Cartesian3.clone(positions.capCenter); const axes = enuAxes(mount); const { forwardHoriz, right, view } = viewAxesFor( camera.headingDeg, camera.pitchDeg, axes, ); const startCal = { ...camera.calibration }; const basePose = { ...camera.basePose }; const state = { // The drag is PINNED to this record: patches must never follow a // mid-drag active-camera switch (voice select / auto-hop) onto a // different camera — its basePose makes the captured offsets wrong. record, part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view, effectiveRangeM: geometry.rangeM, startAngle: null, startParam: null, }; if (part === 'ring-heading') { const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up); if (!hit) return false; state.startAngle = ringAngle(hit, mount, axes.north, axes.east); // compass convention } else if (part === 'ring-pitch') { const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right); if (!hit) return false; state.startAngle = ringAngle(hit, mount, forwardHoriz, axes.up); // 0 = level, + = up } else if ( part === 'move-east' || part === 'move-north' || part === 'move-up' ) { const dir = part === 'move-east' ? axes.east : part === 'move-north' ? axes.north : axes.up; state.axisDir = dir; const t = closestParamOnAxis(ray.origin, ray.direction, mount, dir); if (t === null) return false; state.startParam = t; } else if (part === 'handle-range') { const t = closestParamOnAxis(ray.origin, ray.direction, mount, view); if (t === null) return false; state.startParam = t; } else if (part === 'handle-fov-l' || part === 'handle-fov-r') { const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view); if (!hit) return false; } else { return false; } drag = state; setCursor('grabbing'); if (scene.screenSpaceCameraController) { scene.screenSpaceCameraController.enableInputs = false; } return true; } /** Converts the current mouse ray into a calibration patch for the drag part. */ function dragPatch(windowPosition) { const ray = pickRay(windowPosition); if (!ray || !drag) return null; const { part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view, } = drag; if (part === 'ring-heading') { const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up); if (!hit) return null; const angle = ringAngle(hit, mount, axes.north, axes.east); return { headingDeg: startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)), }; } if (part === 'ring-pitch') { const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right); if (!hit) return null; const angle = ringAngle(hit, mount, forwardHoriz, axes.up); return { pitchDeg: startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)), }; } if (part === 'move-east' || part === 'move-north' || part === 'move-up') { const t = closestParamOnAxis( ray.origin, ray.direction, mount, drag.axisDir, ); if (t === null) return null; const delta = t - drag.startParam; if (part === 'move-east') return { offsetEastM: startCal.offsetEastM + delta }; if (part === 'move-north') return { offsetNorthM: startCal.offsetNorthM + delta }; return { heightM: startCal.heightM + delta }; } if (part === 'handle-range') { const t = closestParamOnAxis(ray.origin, ray.direction, mount, view); if (t === null || !(basePose.rangeM > 0)) return null; return { rangeScale: Math.max(0.01, t) / basePose.rangeM }; } if (part === 'handle-fov-l' || part === 'handle-fov-r') { const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view); if (!hit) return null; const lateral = Math.abs( Cesium.Cartesian3.dot( Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()), right, ), ); const halfW = Math.max(0.5, lateral); const fovDeg = toDeg( 2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)), ); return { fovDeg: fovDeg - basePose.fovDeg }; } return null; } function endDrag() { if (!drag) return; const record = drag.record; drag = null; if (scene.screenSpaceCameraController) { scene.screenSpaceCameraController.enableInputs = true; } setCursor(hoveredId ? 'grab' : ''); endPatch(record); } /** QA/debug tracing, on when the page sets `window.__gevGizmoDebug = true`. */ function debugLog(...args) { if (typeof window !== 'undefined' && window.__gevGizmoDebug) { console.debug('[CCTV:gizmo]', ...args); } } handler.setInputAction((event) => { // A tool owns the pointer (src/data/inputOwnership.js): yield the press. if (!isPointerFree()) return; if (!enabled) return; const part = pickGizmoPart(event.position); debugLog('LEFT_DOWN', event.position, 'part:', part); if (!part) return; const started = beginDrag(part, event.position); debugLog('beginDrag', part, '→', started); }, Cesium.ScreenSpaceEventType.LEFT_DOWN); handler.setInputAction((event) => { if (!enabled) return; const now = Date.now(); if (drag) { if (now - lastDragAt < DRAG_THROTTLE_MS) return; lastDragAt = now; // A mid-drag active-camera switch (voice select / auto-hop) ends the // drag: what was dragged so far stays committed to the PINNED record. if (getActiveRecord() !== drag.record) { debugLog('drag ended: active camera changed mid-drag'); endDrag(); return; } const patch = dragPatch(event.endPosition); debugLog( 'dragPatch', drag.part, event.endPosition, '→', patch ? JSON.stringify(patch) : null, ); if (patch) applyPatch(patch, drag.record); return; } if (now - lastHoverAt < HOVER_THROTTLE_MS) return; lastHoverAt = now; setHovered(pickGizmoPart(event.endPosition)); }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); handler.setInputAction(() => { if (!enabled) return; endDrag(); }, Cesium.ScreenSpaceEventType.LEFT_UP); return { setEnabled(value) { enabled = !!value; if (!enabled) { if (drag) endDrag(); setHovered(null); hideAll(); } else { refresh(); } }, refresh, destroy() { if (drag) endDrag(); handler.destroy(); for (const entity of entities.values()) { viewer.entities.remove(entity); } entities.clear(); setCursor(''); }, isDragging: () => !!drag, isEnabled: () => enabled, }; }