Merge branch 'main' into sameh/vessel-photos

This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-12 20:32:03 -08:00
commit e4832c589c
10 files changed
+151 -10

No files matched your search

+1
View File
@@ -29,6 +29,7 @@ export const writeCalibrationStoreV2 = layer.writeCalibrationStoreV2;
export const deriveCalBadge = layer.deriveCalBadge;
export const computeFrustumGeometry = layer.computeFrustumGeometry;
export const activationProbeClampRange = layer.activationProbeClampRange;
export const flatGroundAxisDistance = layer.flatGroundAxisDistance;
export const frameSignatureFromPixels = layer.frameSignatureFromPixels;
export const _createCctvProjectionPlaneForTest =
layer._createCctvProjectionPlaneForTest;
+12
View File
@@ -35,6 +35,7 @@ import cctvLayer, {
_pushAmbientCardEntriesForTest,
_setCctvOverlayHostForTest,
activationProbeClampRange,
flatGroundAxisDistance,
bindCctvWorldClickGesture,
clearProbeClampOnDeactivation,
computeFrustumGeometry,
@@ -655,6 +656,17 @@ test('activation obstruction clamp uses the field-derived 12 m floor', () => {
assert.equal(activationProbeClampRange(210, 250), null);
});
test('on a globe map the axis ends on flat ground at the mount ground', () => {
// A 24 m mount pitched 30° down meets the ground 48 m along its axis.
assert.ok(Math.abs(flatGroundAxisDistance(24, -30) - 48) < 1e-9);
// The default 700 m range then clamps just short of that, not 700 m out.
const clamp = activationProbeClampRange(700, flatGroundAxisDistance(24, -30));
assert.ok(Math.abs(clamp - 44) < 1e-9);
assert.equal(flatGroundAxisDistance(24, 0), null);
assert.equal(flatGroundAxisDistance(24, 10), null);
assert.equal(flatGroundAxisDistance(0, -30), null);
});
test('deactivation clears the probe clamp and restores nominal frustum geometry', () => {
const record = {
camera: { ...UNCLAMPED_CAMERA },
+51 -7
View File
@@ -260,6 +260,21 @@ export function createGeometry({ state: layerState, services, parts, source }) {
return Math.max(PROBE_MIN_RANGE_M, hitDistance - PROBE_CLEARANCE_M);
}
/**
* Distance along a pitched-down view axis to flat ground at the mount's own
* ground height, or null for an axis at or above the horizon.
* @param {number} mountHeightM Mount height above its ground.
* @param {number} pitchDeg Axis pitch (negative looks down).
* @returns {number|null}
*/
function flatGroundAxisDistance(mountHeightM, pitchDeg) {
const height = Number(mountHeightM);
const pitch = Number(pitchDeg);
if (!Number.isFinite(height) || height <= 0 || !(pitch < 0)) return null;
return height / Math.sin(Cesium.Math.toRadians(Math.min(89, -pitch)));
}
/**
* Converts a computeFrustumGeometry result into the Cartesian3 positions the
* entities consume. Fresh objects per call (geometry updates are rare —
@@ -616,10 +631,14 @@ export function createGeometry({ state: layerState, services, parts, source }) {
* range, clamp the plane's effective range just short of the first hit so the
* "big and dramatic" true end cap never clips into downtown buildings.
*
* This is the ONLY raycast in the whole CCTV subsystem — once per activation,
* never per-frame (the zero-raycast invariant applies to steady state). The
* per-camera range slider overrides the clamp: a user-set rangeScale skips the
* probe entirely. Probe failure/miss keeps the unclamped range.
* On a globe map it also picks the globe's terrain along the same axis
* (flat ground at the mount's ground when terrain is missed or met beyond
* the range) and clamps at the nearer end. These are the ONLY raycasts in the
* whole CCTV subsystem — once per activation, never per-frame (the
* zero-raycast invariant applies to steady state). The per-camera range
* slider overrides the clamp: a user-set rangeScale skips the probe
* entirely. A miss on the photoreal stack or a probe failure keeps the
* unclamped range.
* @param {Object} record - Camera record being activated.
*/
@@ -646,9 +665,33 @@ export function createGeometry({ state: layerState, services, parts, source }) {
for (const runtime of layerState._projectionEntities) {
if (runtime?.planeEntity) exclude.push(runtime.planeEntity);
}
const hit = scene.pickFromRay(new Cesium.Ray(mountPos, dir), exclude);
if (!hit?.position) return;
const dist = Cesium.Cartesian3.distance(mountPos, hit.position);
const ray = new Cesium.Ray(mountPos, dir);
// pickFromRay hits only primitives (the photoreal tiles), never the
// globe: on a globe map the ray runs on through the Earth and can meet
// a primitive thousands of kilometres away, which blocks nothing. Such
// a hit or none left the plane at the full nominal range, about a
// kilometre wide and hundreds of metres up. A hit at or beyond the range
// counts as none. On a globe map the axis also ends on the globe's
// terrain, the nearer of that and any primitive hit (a primitive behind
// a hill the axis has already entered blocks nothing). Terrain still
// loading at a coarse level can be missed or met beyond the range; then
// flat ground at the mount's own ground stands in for it.
const inRange = (distance) =>
Number.isFinite(distance) && distance > 0 && distance < camera.rangeM;
const hit = scene.pickFromRay(ray, exclude)?.position;
const hitDist = hit ? Cesium.Cartesian3.distance(mountPos, hit) : NaN;
const distances = inRange(hitDist) ? [hitDist] : [];
if (scene.globe?.show) {
const terrain = scene.globe.pick(ray, scene);
const terrainDist = terrain
? Cesium.Cartesian3.distance(mountPos, terrain)
: NaN;
const ground = inRange(terrainDist)
? terrainDist
: flatGroundAxisDistance(camera.mountHeightM, camera.pitchDeg);
if (Number.isFinite(ground) && ground > 0) distances.push(ground);
}
const dist = distances.length ? Math.min(...distances) : null;
record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist);
} catch {
// probe failure → keep the unclamped range
@@ -831,6 +874,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
hasShippedFootprint,
footprintPose,
activationProbeClampRange,
flatGroundAxisDistance,
frustumCartesians,
frustumFrameEcef,
applyFrustumGeometry,
+1
View File
@@ -73,6 +73,7 @@ export function createCctvLayer({ services, source }) {
deriveCalBadge: parts.calibration.deriveCalBadge,
computeFrustumGeometry: parts.geometry.computeFrustumGeometry,
activationProbeClampRange: parts.geometry.activationProbeClampRange,
flatGroundAxisDistance: parts.geometry.flatGroundAxisDistance,
frameSignatureFromPixels: parts.frames.frameSignatureFromPixels,
_createCctvProjectionPlaneForTest:
parts.testing._createCctvProjectionPlaneForTest,
+45
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { CctvControls } from './cctvControls.js';
import { CCTV_FRAME_DECODE_WAIT_MS } from './cctvFrames.js';
function element() {
const classes = new Set();
@@ -59,6 +60,50 @@ test('a late image completion cannot replace a newer camera preview', (t) => {
assert.equal(controls._frame.cameraId, 'b');
});
test('a camera frame settles once decoded, and a stale decode cannot land', async (t) => {
const { controls, requests } = fixture(t);
const decodes = [];
const queue = (src, id) => {
controls._queueCctvFrame(src, id, true);
const request = requests.at(-1);
request.decode = () =>
new Promise((resolve) => decodes.push({ src, resolve }));
return request;
};
const first = queue('first.jpg', 'a');
first.onload();
assert.equal(controls._frame.hasFrame, false);
const second = queue('second.jpg', 'b');
second.onload();
decodes[1].resolve();
await Promise.resolve();
decodes[0].resolve();
await Promise.resolve();
assert.equal(controls._frame.src, 'second.jpg');
assert.equal(controls._frame.cameraId, 'b');
assert.equal(controls._frame.hasFrame, true);
});
test('a loaded frame shows when its decode rejects or never settles', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { controls, requests } = fixture(t);
controls._queueCctvFrame('broken.jpg', 'a', true);
requests[0].decode = () => Promise.reject(new Error('EncodingError'));
requests[0].onload();
await Promise.resolve();
await Promise.resolve();
assert.equal(controls._frame.src, 'broken.jpg');
assert.equal(controls._frame.hasFrame, true);
controls._queueCctvFrame('hidden.jpg', 'b', true);
requests[1].decode = () => new Promise(() => {});
requests[1].onload();
assert.equal(controls._frame.hasFrame, false);
t.mock.timers.tick(CCTV_FRAME_DECODE_WAIT_MS);
assert.equal(controls._frame.src, 'hidden.jpg');
assert.equal(controls._frame.hasFrame, true);
});
test('failed refresh preserves settled pixels, but changing cameras clears them', (t) => {
const { controls, requests } = fixture(t);
controls._queueCctvFrame('first.jpg', 'a', true);
+25 -1
View File
@@ -77,6 +77,9 @@ export function cctvSourceLabelText(label) {
return SOURCE_LABELS[text] ? t(SOURCE_LABELS[text]) : text;
}
/** Longest wait for a loaded frame's decode before it is shown anyway. */
export const CCTV_FRAME_DECODE_WAIT_MS = 1000;
/** The preview before any frame: nothing requested, nothing shown. */
export const EMPTY_CCTV_FRAME = Object.freeze({
cameraId: '',
@@ -127,7 +130,28 @@ export function _queueCctvFrame(src, cameraId, cameraChanged) {
const preloader = new Image();
this._cctvFramePreloader = preloader;
preloader.onload = () => this._settleCctvFrame(token, src, true);
// Settle once the image is decoded, not merely loaded: the panel's <img>
// then paints the frame the moment it takes the src, instead of showing
// its empty box for the decode.
// A decode that rejects after a load still has drawable data, as it was
// before decoding was awaited. A decode that never settles (a hidden tab may
// hold it until the page draws) shows the loaded frame after
// CCTV_FRAME_DECODE_WAIT_MS rather than leaving the preview loading.
preloader.onload = () => {
if (typeof preloader.decode !== 'function') {
this._settleCctvFrame(token, src, true);
return;
}
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
this._settleCctvFrame(token, src, true);
};
const timer = setTimeout(settle, CCTV_FRAME_DECODE_WAIT_MS);
preloader.decode().then(settle, settle);
};
preloader.onerror = () => this._settleCctvFrame(token, src, false);
preloader.src = src;
}
+4 -1
View File
@@ -250,8 +250,11 @@ html:not([lang='en']) .cctv-controls .scene-btn {
height: auto;
object-fit: contain;
display: block;
/* No fade: a camera change hides the old frame at once (never its pixels
under the new camera) and shows the new one as soon as it is decoded. A
180 ms fade-in after every arriving frame made each camera click linger
on black. */
opacity: 0;
transition: opacity 180ms ease;
}
#cctv-video {