refactor(cctv): isolate calibration handles and stop idle poll frames

Move native gizmo entities and input into an owned renderer. Restore the
captured camera-input flag, handle cancellation and duplicate presses, and
reject foreign handle identities. Preserve native geometry and picking.

Avoid no-op sync-chip mutations and let unchanged CCTV polls complete
without publishing scene changes. Keep health/UI polling and real geometry,
auto-hop and media updates. Extend browser idle samples across two polls.

Validated full Node24 unit/allocation, boundaries, scoped format, production,
panel and QA builds; native legacy parity, real media/GPU picks on dev and
preview, engine contracts and production shell. Retain failed evidence.
This commit is contained in:
German Khokhlov committed 2026-10-28 22:44:27 +04:00
1 parent a72cfc4098
commit 311e178d0c
14 files changed
+649 -197

No files matched your search

+3
View File
@@ -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",
+3
View File
@@ -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",
+2
View File
@@ -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,
+81 -186
View File
@@ -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('');
},
+16 -3
View File
@@ -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 () => {
+4 -1
View File
@@ -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)
+1 -1
View File
@@ -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()
+233
View File
@@ -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);
});
+11 -2
View File
@@ -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,
};
},
};
+76
View File
@@ -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);
});
-3
View File
@@ -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();
+193
View File
@@ -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('');
},
};
}
+24
View File
@@ -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);
+2 -1
View File
@@ -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');
}
}