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

+2
View File
@@ -0,0 +1,2 @@
- Show a new CCTV camera's frame the moment it is decoded, with no fade, so
switching cameras no longer lingers on an empty preview (samehkhamis, #1693).
@@ -0,0 +1,3 @@
- End a CCTV camera's monitor plane where its view meets the ground on globe
maps too, instead of a kilometre-wide plane hundreds of metres up
(samehkhamis, #1694).
+7 -1
View File
@@ -2224,7 +2224,9 @@ Frame loading, calibration editing and status display have separate modules;
providers, camera placement and navigation policy retain their existing owners.
Changing cameras invalidates old image callbacks and cancels an unfinished
calibration edit. Failed refreshes preserve settled pixels for the same camera,
while a newly selected camera never shows the prior camera's image. Disposal
while a newly selected camera never shows the prior camera's image. A frame is
shown without a fade once it is decoded (or after one second if its decode never
settles), so a new camera's preview is blank only while its frame loads. Disposal
releases listeners, subscriptions, image handlers, summary timers and chip-hide timers.
## UI disposal
@@ -6579,6 +6581,10 @@ silently demoting every later lookup for the session.
far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only —
auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction
probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains;
a hit at or beyond the camera's range counts as a miss, and on a globe map the plane also clamps
short of the globe's terrain along the axis (flat ground at the mount's ground when terrain is
missed or met beyond the range), whichever is nearer, so it never stands at the full range there
(about a kilometre wide and hundreds of metres up);
ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to
`godsEyeView.cctv.calibration.v2` (wiped clean, no v1 import); a panel-only CAL badge shows
`CALIBRATED`/`CURATED`/`RAW PRIOR` (no in-world tint). Panel is titled "CCTV" (not "CCTV
+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 {