Files
gods-eye-view/src/data/cctvGizmo.js
T
Sameh Khamis dbb20c72fe chore: drop unused local bindings
Remove locals that are assigned but never read: two unused
JulianDate.now() calls in the flights and military nearby queries, an
unread record lookup in the military snapshot renderer, unused axes from
the CCTV gizmo, an unused vessel state alias, and two unused ALPR
presentation handles. Each removed expression is side-effect free.
2026-10-22 18:09:40 -07:00

702 lines
24 KiB
JavaScript
Raw Blame History

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