diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7498a99b7..e4fdef499 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -540,10 +540,13 @@ "src/layers/cctv/policy.js", "src/layers/cctv/projection.js", "src/layers/cctv/projectionScheduling.test.mjs", + "src/layers/cctv/gizmo.test.mjs", + "src/layers/cctv/render/cesiumGizmo.js", "src/layers/cctv/cards.js", "src/layers/cctv/rendering.js", "src/layers/cctv/testing.js", "src/layers/cctv/ingestion.js", + "src/layers/cctv/ingestion.test.mjs", "src/layers/cctv/hover.js", "src/layers/cctv/navigation.js", "src/layers/cctv/index.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 19dfc47bd..eb8a7416d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1446,6 +1446,7 @@ "modules": [ "src/data/cctvCards.js", "src/data/cctvGizmo.js", + "src/layers/shared/color.js", "src/data/cctvLod.js", "src/data/cctvViewshed.js", "src/data/iconOrientation.js", @@ -1833,6 +1834,7 @@ "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", "src/app/layers/cctv.js", + "src/layers/cctv/render/cesiumGizmo.js", "src/app/layers/earthquakes.js", "src/app/layers/firms.js", "src/app/layers/flights.js", @@ -2790,6 +2792,7 @@ "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", "src/app/layers/cctv.js", + "src/layers/cctv/render/cesiumGizmo.js", "src/app/layers/earthquakes.js", "src/app/layers/firms.js", "src/app/layers/flights.js", diff --git a/src/app/layers/cctv.js b/src/app/layers/cctv.js index 60cc64d0f..7949fa1ec 100644 --- a/src/app/layers/cctv.js +++ b/src/app/layers/cctv.js @@ -1,3 +1,4 @@ +import { createCesiumCctvGizmoRenderer } from '../../layers/cctv/render/cesiumGizmo.js'; import { createCctvLayer } from '../../layers/cctv/index.js'; import * as credits from '../../data/dataCredits.js'; import * as sprites from '../../data/spriteOrder.js'; @@ -14,6 +15,7 @@ export function createApplicationCctv({ surface, source }) { return createCctvLayer({ source, services: { + createGizmoRenderer: createCesiumCctvGizmoRenderer, credits, sprites, activation, diff --git a/src/data/cctvGizmo.js b/src/data/cctvGizmo.js index 6e7e18bcb..effd257e2 100644 --- a/src/data/cctvGizmo.js +++ b/src/data/cctvGizmo.js @@ -8,7 +8,7 @@ * - 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 + * - createCalibrationGizmo (below): the engine-neutral drag * 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. @@ -23,7 +23,8 @@ * throttled hover picks while ADJUST mode is on) — the layer's zero-steady-state-query * invariant is untouched. */ -import * as Cesium from 'cesium'; +import * as geo from '../geo/index.js'; +import { rgbaFromCss } from '../layers/shared/color.js'; import { isPointerFree } from './inputOwnership.js'; // Grazing guard (spec §5): reject plane intersections when the view ray is @@ -33,7 +34,7 @@ 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(); +const scratchW = geo.cartesian3.create(); /** * Parameter t (metres) along an axis line of the point on that axis closest @@ -50,12 +51,12 @@ const scratchW = new Cesium.Cartesian3(); * @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 b = geo.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); + const w = geo.cartesian3.subtract(rayOrigin, axisOrigin, scratchW); + const d = geo.cartesian3.dot(rayDir, w); + const e = geo.cartesian3.dot(axisDir, w); return (e - b * d) / denom; } @@ -69,17 +70,17 @@ export function closestParamOnAxis(rayOrigin, rayDir, axisOrigin, axisDir) { * @returns {Cesium.Cartesian3|null} Hit point (new instance), or null. */ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) { - const denom = Cesium.Cartesian3.dot(rayDir, planeNormal); + const denom = geo.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; + const toPlane = geo.cartesian3.subtract(planeOrigin, rayOrigin, scratchW); + const s = geo.cartesian3.dot(toPlane, planeNormal) / denom; if (s < 0) return null; - const hit = Cesium.Cartesian3.multiplyByScalar( + const hit = geo.cartesian3.multiplyByScalar( rayDir, s, - new Cesium.Cartesian3(), + geo.cartesian3.create(), ); - return Cesium.Cartesian3.add(rayOrigin, hit, hit); + return geo.cartesian3.add(rayOrigin, hit, hit); } /** @@ -92,10 +93,10 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) { * @returns {number} Angle in (−π, π]. */ export function ringAngle(hitPoint, center, basisA, basisB) { - const v = Cesium.Cartesian3.subtract(hitPoint, center, scratchW); + const v = geo.cartesian3.subtract(hitPoint, center, scratchW); return Math.atan2( - Cesium.Cartesian3.dot(v, basisB), - Cesium.Cartesian3.dot(v, basisA), + geo.cartesian3.dot(v, basisB), + geo.cartesian3.dot(v, basisA), ); } @@ -130,12 +131,12 @@ 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 COLOR_RING_HEADING = rgbaFromCss('#35d8ff'); +const COLOR_RING_PITCH = rgbaFromCss('#ff5fd0'); +const COLOR_MOVE_EAST = rgbaFromCss('#ff5252'); +const COLOR_MOVE_NORTH = rgbaFromCss('#52ff7a'); +const COLOR_MOVE_UP = rgbaFromCss('#5b8cff'); +const COLOR_HANDLE = rgbaFromCss('#ffd97a'); const toRadians = (deg) => (deg * Math.PI) / 180; const toDeg = (rad) => (rad * 180) / Math.PI; @@ -146,24 +147,26 @@ const toDeg = (rad) => (rad * 180) / Math.PI; * @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()); + const frame = geo.transforms.eastNorthUpToFixedFrame(position); + const rot = geo.matrix4.getMatrix3(frame, geo.matrix3.create()); 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()), + east: geo.matrix3.getColumn(rot, 0, geo.cartesian3.create()), + north: geo.matrix3.getColumn(rot, 1, geo.cartesian3.create()), + up: geo.matrix3.getColumn(rot, 2, geo.cartesian3.create()), }; } /** a*sa + b*sb (fresh Cartesian3). */ function combine2(a, sa, b, sb) { - const out = Cesium.Cartesian3.multiplyByScalar( + // Retain the existing call layout during engine extraction. + // prettier-ignore + const out = geo.cartesian3.multiplyByScalar( a, sa, - new Cesium.Cartesian3(), + geo.cartesian3.create(), ); - const t = Cesium.Cartesian3.multiplyByScalar(b, sb, new Cesium.Cartesian3()); - return Cesium.Cartesian3.add(out, t, out); + const t = geo.cartesian3.multiplyByScalar(b, sb, geo.cartesian3.create()); + return geo.cartesian3.add(out, t, out); } /** @@ -198,7 +201,7 @@ function ringPositions(center, radius, basisA, basisB) { basisB, Math.sin(angle) * radius, ); - positions.push(Cesium.Cartesian3.add(center, offset, offset)); + positions.push(geo.cartesian3.add(center, offset, offset)); } return positions; } @@ -210,7 +213,7 @@ function ringPositions(center, radius, basisA, basisB) { * never touches layer records/stores directly. * * @param {Object} deps - * @param {Cesium.Viewer} deps.viewer + * @param {Object} deps.renderer - Layer-owned handle renderer and input. * @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). @@ -224,7 +227,7 @@ function ringPositions(center, radius, basisA, basisB) { * isEnabled: function(): boolean}} */ export function createCalibrationGizmo({ - viewer, + renderer, getActiveRecord, applyPatch, endPatch, @@ -236,69 +239,27 @@ export function createCalibrationGizmo({ let lastHoverAt = 0; const entities = new Map(); // part -> Entity - const scene = viewer.scene; - const handler = new Cesium.ScreenSpaceEventHandler(scene.canvas); + const { pickRay, setCursor } = renderer; + const pickGizmoPart = (position) => renderer.pickPart(position); + const polylinePart = (part, ...args) => + entities.set(part, renderer.createLine(part, ...args)); + const pointPart = (part, ...args) => + entities.set(part, renderer.createPoint(part, ...args)); - /** 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; + try { + 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); + } catch (error) { + renderer.destroy(); + throw error; } - 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; } @@ -342,14 +303,16 @@ export function createCalibrationGizmo({ ); const arrowTo = (dir) => [ mount, - Cesium.Cartesian3.add( + // Retain the existing call layout during engine extraction. + // prettier-ignore + geo.cartesian3.add( mount, - Cesium.Cartesian3.multiplyByScalar( + geo.cartesian3.multiplyByScalar( dir, arrowLen, - new Cesium.Cartesian3(), + geo.cartesian3.create(), ), - new Cesium.Cartesian3(), + geo.cartesian3.create(), ), ]; const eastArrow = arrowTo(axes.east); @@ -362,82 +325,19 @@ export function createCalibrationGizmo({ 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( + entities.get('handle-fov-l').position = geo.cartesian3.midpoint( positions.tl, positions.bl, - new Cesium.Cartesian3(), + geo.cartesian3.create(), ); - entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint( + entities.get('handle-fov-r').position = geo.cartesian3.midpoint( positions.tr, positions.br, - new Cesium.Cartesian3(), + geo.cartesian3.create(), ); 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; @@ -468,8 +368,8 @@ export function createCalibrationGizmo({ const ray = pickRay(windowPosition); if (!ray) return false; - const mount = Cesium.Cartesian3.clone(positions.mount); - const capCenter = Cesium.Cartesian3.clone(positions.capCenter); + const mount = geo.cartesian3.clone(positions.mount); + const capCenter = geo.cartesian3.clone(positions.capCenter); const axes = enuAxes(mount); const { forwardHoriz, right, view } = viewAxesFor( camera.headingDeg, @@ -533,9 +433,7 @@ export function createCalibrationGizmo({ drag = state; setCursor('grabbing'); - if (scene.screenSpaceCameraController) { - scene.screenSpaceCameraController.enableInputs = false; - } + state.releaseInputs = renderer.suspendCameraInputs(); return true; } @@ -597,8 +495,8 @@ export function createCalibrationGizmo({ 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()), + geo.cartesian3.dot( + geo.cartesian3.subtract(hit, capCenter, geo.cartesian3.create()), right, ), ); @@ -614,10 +512,9 @@ export function createCalibrationGizmo({ function endDrag() { if (!drag) return; const record = drag.record; + const releaseInputs = drag.releaseInputs; drag = null; - if (scene.screenSpaceCameraController) { - scene.screenSpaceCameraController.enableInputs = true; - } + releaseInputs(); setCursor(hoveredId ? 'grab' : ''); endPatch(record); } @@ -629,18 +526,18 @@ export function createCalibrationGizmo({ } } - handler.setInputAction((event) => { + renderer.on('down', (event) => { // A tool owns the pointer (src/data/inputOwnership.js): yield the press. if (!isPointerFree()) return; - if (!enabled) return; + if (!enabled || drag) 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) => { + renderer.on('move', (event) => { if (!enabled) return; const now = Date.now(); if (drag) { @@ -667,12 +564,13 @@ export function createCalibrationGizmo({ if (now - lastHoverAt < HOVER_THROTTLE_MS) return; lastHoverAt = now; setHovered(pickGizmoPart(event.endPosition)); - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + }); - handler.setInputAction(() => { + renderer.on('up', () => { if (!enabled) return; endDrag(); - }, Cesium.ScreenSpaceEventType.LEFT_UP); + }); + renderer.on('cancel', endDrag); return { setEnabled(value) { @@ -688,10 +586,7 @@ export function createCalibrationGizmo({ refresh, destroy() { if (drag) endDrag(); - handler.destroy(); - for (const entity of entities.values()) { - viewer.entities.remove(entity); - } + renderer.destroy(); entities.clear(); setCursor(''); }, diff --git a/src/data/detectionRenderDemand.test.mjs b/src/data/detectionRenderDemand.test.mjs index e104684cc..02eb87db9 100644 --- a/src/data/detectionRenderDemand.test.mjs +++ b/src/data/detectionRenderDemand.test.mjs @@ -352,7 +352,7 @@ test('detection holds nothing, and asks for its own frames instead', async () => 'and from the shared policy so the two cannot drift'); }); -test('a layer whose detectable set changed dirties the solve', async () => { +test('a layer whose detectable set changed dirties the solve', async (t) => { // Detection PULLS candidates per paint but re-solves on a private 125 ms // throttle. A poll tick that replaces contact A with contact B requests one // frame; without this, that frame could be spent on a paint which declined to @@ -375,6 +375,10 @@ test('a layer whose detectable set changed dirties the solve', async () => { requestRender: (reason) => reactions.push(['render', reason]), invalidateDetection: (reason) => reactions.push(['detection', reason]), }); + t.after(async () => { + presentation.destroy(); + await manager.destroyAll(); + }); let result = true; manager.register({ id: 'fixture', name: 'Fixture', updateInterval: 0, init() {}, enable() {}, disable() {}, destroy() {}, @@ -387,6 +391,17 @@ test('a layer whose detectable set changed dirties the solve', async () => { await manager.refreshLayer('fixture'); assert.deepEqual(reactions, [['render', 'layer-tick:fixture'], ['detection', 'layer-tick:fixture']]); reactions.length = 0; + result = { changed: false }; + assert.equal(await manager.refreshLayer('fixture'), true); + assert.deepEqual(reactions, [], 'an explicit unchanged scene still completes refresh without waking rendering'); + result = { changed: true }; + assert.equal(await manager.refreshLayer('fixture'), true); + assert.deepEqual(reactions, [['render', 'layer-tick:fixture'], ['detection', 'layer-tick:fixture']]); + reactions.length = 0; + result = undefined; + assert.equal(await manager.refreshLayer('fixture'), true); + assert.deepEqual(reactions, [['render', 'layer-tick:fixture'], ['detection', 'layer-tick:fixture']], 'legacy void updates retain their render and detection request'); + reactions.length = 0; result = false; await manager.refreshLayer('fixture'); assert.deepEqual(reactions, [['render', 'layer-tick:fixture'], ['detection', 'layer-tick:fixture']], 'partial/rejected updates still invalidate detection'); @@ -397,8 +412,6 @@ test('a layer whose detectable set changed dirties the solve', async () => { result = true; await manager.setEnabled('fixture', false); assert.deepEqual(reactions, [['render', 'layer-visibility'], ['detection', 'layer-visibility']]); - presentation.destroy(); - await manager.destroyAll(); }); test('the render-governor gate covers the parked case, with teeth on the painter', async () => { diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 5a1eb9d8a..fae5a1205 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -312,7 +312,10 @@ export class LayerLifecycle { ); // Even a rejected/partial update may have changed the data exposed by // the layer. Publish before classifying its result so observers see it. - this._publishActivity({ type: 'data-updated', layerId }); + // A layer may explicitly report unchanged scene data while still + // completing health/UI polling. Legacy void/boolean results keep waking. + if (result?.changed !== false) + this._publishActivity({ type: 'data-updated', layerId }); if (result === false) failure = lifecycleRejectedError(layerId, 'refresh'); if (!failure) diff --git a/src/layers/cctv/calibration.js b/src/layers/cctv/calibration.js index 2603c843b..be71125d8 100644 --- a/src/layers/cctv/calibration.js +++ b/src/layers/cctv/calibration.js @@ -306,7 +306,7 @@ export function createCalibration({ ? record : null; layerState._gizmo = createCalibrationGizmo({ - viewer: layerState._viewer, + renderer: services.createGizmoRenderer(layerState._viewer), getActiveRecord: () => layerState._enabled && layerState._calibrationMode ? parts.selection.getActiveRecord() diff --git a/src/layers/cctv/gizmo.test.mjs b/src/layers/cctv/gizmo.test.mjs new file mode 100644 index 000000000..216416a1a --- /dev/null +++ b/src/layers/cctv/gizmo.test.mjs @@ -0,0 +1,233 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createCalibrationGizmo } from '../../data/cctvGizmo.js'; +import { createCesiumCctvGizmoRenderer } from './render/cesiumGizmo.js'; + +function setup(t, initialInputs = true) { + const actions = new Map(); + const entities = new Cesium.EntityCollection(); + const controller = { enableInputs: initialInputs }; + const canvas = new EventTarget(); + canvas.style = {}; + canvas.ownerDocument = { defaultView: new EventTarget() }; + let picked = null; + let destroys = 0; + const viewer = { + entities, + scene: { + canvas, + screenSpaceCameraController: controller, + pick: () => picked, + drillPick: () => [], + }, + camera: { + getPickRay: ({ x }) => ({ + origin: new Cesium.Cartesian3(6378237, x, 0), + direction: new Cesium.Cartesian3(-1, 0, 0), + }), + }, + }; + const renderer = createCesiumCctvGizmoRenderer(viewer, { + createEventHandler: () => ({ + setInputAction: (listener, type) => actions.set(type, listener), + destroy() { + destroys++; + actions.clear(); + }, + }), + }); + const p = (x, y, z) => new Cesium.Cartesian3(x, y, z); + const record = { + camera: { + headingDeg: 0, + pitchDeg: 0, + rangeM: 100, + calibration: { + headingDeg: 0, + pitchDeg: 0, + offsetEastM: 0, + offsetNorthM: 0, + heightM: 0, + rangeScale: 1, + fovDeg: 0, + }, + basePose: { rangeM: 100, fovDeg: 60 }, + }, + frustumGeometry: { rangeM: 100 }, + frustumPositions: { + mount: p(6378137, 0, 0), + capCenter: p(6378137, 0, 100), + tl: p(6378150, -20, 100), + tr: p(6378150, 20, 100), + bl: p(6378124, -20, 100), + br: p(6378124, 20, 100), + }, + }; + let active = record; + const patches = [], + ends = []; + const gizmo = createCalibrationGizmo({ + renderer, + getActiveRecord: () => active, + applyPatch: (patch, pinned) => patches.push({ patch, pinned }), + endPatch: (pinned) => ends.push(pinned), + }); + t.after(() => gizmo.destroy()); + gizmo.setEnabled(true); + const event = (type, x = 10) => + actions.get(Cesium.ScreenSpaceEventType[type])?.({ + position: { x, y: 0 }, + endPosition: { x, y: 0 }, + }); + return { + gizmo, + renderer, + record, + entities, + controller, + patches, + ends, + canvas, + event, + choose: (entity = entities.getById('cctv-gizmo-move-east')) => { + picked = { id: entity }; + }, + switchCamera: () => { + active = { ...record }; + }, + destroys: () => destroys, + }; +} + +for (const initial of [false, true]) { + for (const ending of ['release', 'disable', 'destroy', 'selection']) { + test(`gizmo ${ending} restores initial camera input ${initial} and ends only the pinned record`, (t) => { + const h = setup(t, initial); + h.choose(); + h.event('LEFT_DOWN'); + assert.equal(h.gizmo.isDragging(), true); + assert.equal(h.controller.enableInputs, false); + if (ending === 'selection') { + h.switchCamera(); + h.event('MOUSE_MOVE', 25); + } else { + h.event('MOUSE_MOVE', 25); + assert.equal(h.patches.length, 1); + assert.equal(h.patches[0].pinned, h.record); + assert.ok(Math.abs(h.patches[0].patch.offsetEastM - 15) < 1e-8); + if (ending === 'release') h.event('LEFT_UP'); + if (ending === 'disable') h.gizmo.setEnabled(false); + if (ending === 'destroy') h.gizmo.destroy(); + } + assert.equal(h.controller.enableInputs, initial); + assert.deepEqual(h.ends, [h.record]); + assert.equal(h.gizmo.isDragging(), false); + h.event('LEFT_UP'); + assert.equal(h.ends.length, 1); + if (ending === 'selection') assert.equal(h.patches.length, 0); + }); + } +} + +test('native gizmo requires the exact owned entity, not a sibling with its ID', (t) => { + const h = setup(t); + const own = h.entities.getById('cctv-gizmo-move-east'); + h.choose({ id: own.id }); + h.event('LEFT_DOWN'); + assert.equal(h.gizmo.isDragging(), false); + h.choose(); + h.event('LEFT_DOWN'); + assert.equal(h.gizmo.isDragging(), true); +}); + +test('gizmo builds eight owned handles with pinned native geometry and releases only those', (t) => { + const h = setup(t); + const now = Cesium.JulianDate.now(); + assert.equal(h.entities.values.length, 8); + for (const entity of h.entities.values) assert.equal(entity.show, true); + const heading = h.entities + .getById('cctv-gizmo-ring-heading') + .polyline.positions.getValue(now); + assert.equal(heading.length, 97); + assert.ok( + Math.abs( + Cesium.Cartesian3.distance(heading[0], h.record.frustumPositions.mount) - + 12, + ) < 1e-8, + ); + const east = h.entities.getById('cctv-gizmo-move-east'); + assert.equal(east.polyline.width.getValue(now), 12); + assert.equal(east.polyline.material.getType(now), 'PolylineArrow'); + assert.ok(Math.abs(east.position.getValue(now).y - 19.2) < 1e-8); + const sibling = h.entities.add({ id: 'foreign' }); + h.gizmo.destroy(); + h.gizmo.destroy(); + assert.deepEqual(h.entities.values, [sibling]); + assert.equal(h.destroys(), 1); +}); + +for (const event of ['pointercancel', 'blur']) { + test(`gizmo ${event} ends a pinned drag and releases the camera`, (t) => { + const h = setup(t); + h.choose(); + h.event('LEFT_DOWN'); + const target = + event === 'blur' ? h.canvas.ownerDocument.defaultView : h.canvas; + target.dispatchEvent(new Event(event)); + assert.equal(h.gizmo.isDragging(), false); + assert.equal(h.controller.enableInputs, true); + assert.deepEqual(h.ends, [h.record]); + h.gizmo.destroy(); + target.dispatchEvent(new Event(event)); + assert.equal(h.ends.length, 1); + }); +} + +test('a duplicate down cannot overwrite the original camera-input state', (t) => { + const h = setup(t); + h.choose(); + h.event('LEFT_DOWN'); + h.event('LEFT_DOWN'); + h.event('LEFT_UP'); + assert.equal(h.controller.enableInputs, true); + assert.deepEqual(h.ends, [h.record]); +}); + +test('native gizmo never deletes an unrelated entity with a reserved handle ID', (t) => { + const h = setup(t); + const id = 'cctv-gizmo-move-east'; + const own = h.entities.getById(id); + h.entities.remove(own); + const sibling = h.entities.add({ id }); + assert.throws( + () => h.renderer.createPoint('move-east', Cesium.Color.RED, 10), + /owned|already|exists/i, + ); + assert.equal(h.entities.getById(id), sibling); + h.choose(own); + h.event('LEFT_DOWN'); + assert.equal(h.gizmo.isDragging(), false); + h.gizmo.destroy(); + assert.equal(h.entities.getById(id), sibling); +}); + +test('a handle construction failure releases already-created resources', () => { + const handles = new Set(); + let destroys = 0; + const renderer = { + createLine(part) { + if (part === 'move-east') throw new Error('ID already owned'); + const handle = { part }; + handles.add(handle); + return handle; + }, + destroy() { + destroys++; + handles.clear(); + }, + }; + assert.throws(() => createCalibrationGizmo({ renderer }), /already owned/); + assert.equal(handles.size, 0); + assert.equal(destroys, 1); +}); diff --git a/src/layers/cctv/ingestion.js b/src/layers/cctv/ingestion.js index 0947ccdb6..879f8eb41 100644 --- a/src/layers/cctv/ingestion.js +++ b/src/layers/cctv/ingestion.js @@ -19,7 +19,9 @@ export function createIngestion({ * tick ever samples anything again. */ async update() { - if (!layerState._enabled) return; + if (!layerState._enabled) return { changed: false }; + let changed = false; + const previousActiveId = layerState._activeCameraId; const now = Date.now(); layerState._lastUpdate = now; if ( @@ -35,12 +37,19 @@ export function createIngestion({ const unresolved = layerState._records.filter( (record) => !parts.ground.isGroundResolved(record), ); - if (unresolved.length) + if (unresolved.length) { parts.geometryQueue.enqueueGeometryRefresh(unresolved); + changed = true; + } } await parts.health.syncHealthState(); parts.navigation.maybeAutoHop(now); parts.presentation.notifyListeners(); + // Health and frame URL notifications update the panel directly. Media + // requests its own frames; only actual scene changes need a tick frame. + return { + changed: changed || previousActiveId !== layerState._activeCameraId, + }; }, }; diff --git a/src/layers/cctv/ingestion.test.mjs b/src/layers/cctv/ingestion.test.mjs new file mode 100644 index 000000000..4091c5d8e --- /dev/null +++ b/src/layers/cctv/ingestion.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createIngestion } from './ingestion.js'; + +function setup() { + const record = { camera: { id: 'a' } }; + const state = { + _enabled: true, + _activeCameraId: 'a', + _tilesReadyReenqueued: true, + _records: [record], + }; + const calls = { health: 0, notify: 0, queues: [] }; + const parts = { + model: { projectionTilesReady: () => true }, + ground: { isGroundResolved: () => false }, + geometryQueue: { + enqueueGeometryRefresh: (records) => calls.queues.push(records), + }, + health: { + async syncHealthState() { + calls.health++; + }, + }, + navigation: { maybeAutoHop() {} }, + presentation: { + notifyListeners() { + calls.notify++; + }, + }, + }; + const layer = createIngestion({ state, parts }).methods; + return { state, parts, calls, layer, record }; +} + +test('CCTV health-only ticks update the panel without claiming scene changes', async () => { + const h = setup(); + for (let i = 0; i < 3; i++) + assert.deepEqual(await h.layer.update(), { changed: false }); + assert.equal(h.calls.health, 3); + assert.equal(h.calls.notify, 3); + assert.ok(h.state._lastUpdate > 0); +}); + +test('CCTV completion refresh publishes geometry work exactly once', async () => { + const h = setup(); + h.state._tilesReadyReenqueued = false; + assert.deepEqual(await h.layer.update(), { changed: true }); + assert.deepEqual(h.calls.queues, [[h.record]]); + assert.deepEqual(await h.layer.update(), { changed: false }); + assert.equal(h.calls.queues.length, 1); +}); + +test('a ready surface with no unresolved records does not claim geometry work', async () => { + const h = setup(); + h.state._tilesReadyReenqueued = false; + h.parts.ground.isGroundResolved = () => true; + assert.deepEqual(await h.layer.update(), { changed: false }); + assert.equal(h.calls.queues.length, 0); +}); + +test('CCTV auto-hop still publishes its changed active camera', async () => { + const h = setup(); + h.parts.navigation.maybeAutoHop = () => { + h.state._activeCameraId = 'b'; + }; + assert.deepEqual(await h.layer.update(), { changed: true }); + assert.deepEqual(await h.layer.update(), { changed: false }); +}); + +test('disabled CCTV does no polling or presentation work', async () => { + const h = setup(); + h.state._enabled = false; + await h.layer.update(); + assert.equal(h.calls.health + h.calls.notify + h.calls.queues.length, 0); +}); diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js index 6d041aa78..4ee652f3a 100644 --- a/src/layers/cctv/lifecycle.js +++ b/src/layers/cctv/lifecycle.js @@ -453,9 +453,6 @@ export function createLifecycle({ layerState._clickHandler.destroy(); layerState._clickHandler = null; } - if (teardownViewer?.scene?.screenSpaceCameraController) { - teardownViewer.scene.screenSpaceCameraController.enableInputs = true; - } parts.projection.stopProjectionLoop(); parts.geometryQueue.stopGeometryLoadQueue(); parts.cards.teardownAmbientCards(); diff --git a/src/layers/cctv/render/cesiumGizmo.js b/src/layers/cctv/render/cesiumGizmo.js new file mode 100644 index 000000000..fca07ed2f --- /dev/null +++ b/src/layers/cctv/render/cesiumGizmo.js @@ -0,0 +1,193 @@ +import * as Cesium from 'cesium'; +import { GIZMO_ID_PREFIX } from '../../../data/cctvGizmo.js'; + +/** Native handle resources and input, owned by exactly one viewer. */ +export function createCesiumCctvGizmoRenderer( + viewer, + { + createEventHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), + } = {}, +) { + const scene = viewer.scene; + const handler = createEventHandler(scene.canvas); + const entities = new Map(); + const removeCancelListeners = []; + let destroyed = false; + /** 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 && stale !== entities.get(part)) + throw new Error(`CCTV handle ID is already owned: ${id}`); + if (stale) viewer.entities.remove(stale); + const entity = viewer.entities.add({ id, show: false, ...options }); + entities.set(part, entity); + return entity; + } + + function createLine(part, color, width, arrow = false) { + color = Cesium.Color.clone(color); + return 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 createPoint(part, color, pixelSize) { + color = Cesium.Color.clone(color); + return addEntity(part, { + position: Cesium.Cartesian3.ZERO, + point: { + pixelSize, + color, + outlineColor: Cesium.Color.BLACK.withAlpha(0.7), + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + } + + /** 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; + const part = id.slice(GIZMO_ID_PREFIX.length); + const entity = entities.get(part); + return entity && + viewer.entities.getById(id) === entity && + (picked.id === entity || picked.primitive?.id === entity) + ? part + : null; + } + + 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; + } + + return { + createLine, + createPoint, + pickPart: pickGizmoPart, + pickRay, + setCursor, + on(type, listener) { + if (type === 'cancel') { + for (const [target, event] of [ + [scene.canvas, 'pointercancel'], + [scene.canvas.ownerDocument?.defaultView, 'blur'], + ]) { + if (!target?.addEventListener) continue; + target.addEventListener(event, listener); + removeCancelListeners.push(() => + target.removeEventListener(event, listener), + ); + } + return; + } + handler.setInputAction( + listener, + { + down: Cesium.ScreenSpaceEventType.LEFT_DOWN, + move: Cesium.ScreenSpaceEventType.MOUSE_MOVE, + up: Cesium.ScreenSpaceEventType.LEFT_UP, + }[type], + ); + }, + suspendCameraInputs() { + const controller = scene.screenSpaceCameraController; + const previous = controller?.enableInputs; + if (controller) controller.enableInputs = false; + let released = false; + return () => { + if (released) return; + released = true; + if (controller) controller.enableInputs = previous; + }; + }, + destroy() { + if (destroyed) return; + destroyed = true; + handler.destroy(); + for (const remove of removeCancelListeners) remove(); + removeCancelListeners.length = 0; + for (const entity of entities.values()) + if (viewer.entities.getById(entity.id) === entity) + viewer.entities.remove(entity); + entities.clear(); + setCursor(''); + }, + }; +} diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 5ab5a3280..e13a74052 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -47,6 +47,30 @@ function fixture(t) { return { controls, requests }; } +test('settled CCTV sync chip does not mutate occluder attributes on polling', (t) => { + const { controls } = fixture(t); + const chip = element(); + const remove = chip.classList.remove; + let mutations = 0; + chip.classList.remove = (...values) => { + mutations++; + remove(...values); + }; + controls._cctvSyncChip = chip; + controls._cctvSyncLabel = element(); + controls._cctvSyncProgress = element(); + const settled = { active: false, total: 25, loaded: 25 }; + controls._updateCctvSyncChip(settled, true); + controls._updateCctvSyncChip(settled, true); + assert.equal(mutations, 0, 'unchanged class writes wake world-overlay'); + chip.classList.add('visible'); + controls._updateCctvSyncChip(settled, false); + assert.equal(chip.classList.contains('visible'), false); + assert.equal(mutations, 1, 'a real visibility transition must still apply'); + controls._updateCctvSyncChip(settled, false); + assert.equal(mutations, 1); +}); + test('a late image completion cannot replace a newer camera preview', (t) => { const { controls, requests } = fixture(t); controls._queueCctvFrame('first.jpg', 'a', true); diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index fef358099..8c4b699c0 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -274,6 +274,7 @@ export function _updateCctvSyncChip(loading, enabled) { if (!this._cctvChipHideTimer) { this._cctvChipWasBusy = false; - this._cctvSyncChip.classList.remove('visible'); + if (this._cctvSyncChip.classList.contains('visible')) + this._cctvSyncChip.classList.remove('visible'); } }