mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
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:
1 parent
a72cfc4098
commit
311e178d0c
14 files changed
+649
-197
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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('');
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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('');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user