From 2445af349d709bdc4b57cbf5c59947f49198124c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 20:10:02 -0800 Subject: [PATCH 1/2] fix(cctv): on a globe map the camera plane ends where the view meets the ground The activation probe raycast only the photoreal tiles. On a globe map the ray missed, or ran on through the Earth and met a primitive thousands of kilometres away, and the monitor plane stood at the camera's full range: about a kilometre wide and hundreds of metres up. A hit at or beyond the range now counts as none, and on a globe map the plane also clamps short of the globe's terrain along the axis, or flat ground at the mount's ground while that terrain is still loading, whichever is nearer. --- docs/CURRENT-STATE.md | 4 +++ src/data/cctv.js | 1 + src/data/cctv.test.mjs | 12 ++++++++ src/layers/cctv/geometry.js | 58 ++++++++++++++++++++++++++++++++----- src/layers/cctv/index.js | 1 + 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 02c1996fb..2487af0a7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6570,6 +6570,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 diff --git a/src/data/cctv.js b/src/data/cctv.js index ed3f56654..884638a96 100644 --- a/src/data/cctv.js +++ b/src/data/cctv.js @@ -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; diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index bcd5c48ab..087422584 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -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 }, diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js index a1402c6c2..148c4a253 100644 --- a/src/layers/cctv/geometry.js +++ b/src/layers/cctv/geometry.js @@ -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, diff --git a/src/layers/cctv/index.js b/src/layers/cctv/index.js index 36e1360b4..ba251da34 100644 --- a/src/layers/cctv/index.js +++ b/src/layers/cctv/index.js @@ -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, From d5b030e7fa404152ca1439925c0162ce03f8eb4a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 20:11:19 -0800 Subject: [PATCH 2/2] docs(changelog): 1694-cctv-globe-plane-clamp --- changelog.d/1694-cctv-globe-plane-clamp.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1694-cctv-globe-plane-clamp.md diff --git a/changelog.d/1694-cctv-globe-plane-clamp.md b/changelog.d/1694-cctv-globe-plane-clamp.md new file mode 100644 index 000000000..54adb8545 --- /dev/null +++ b/changelog.d/1694-cctv-globe-plane-clamp.md @@ -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).