mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 06:42:23 +00:00
Merge branch 'main' into sameh/vessel-photos
This commit is contained in:
10 files changed
+151
-10
No files matched your search
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user