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.
This commit is contained in:
Sameh Khamis committed 2026-11-12 20:10:02 -08:00
1 parent c02ede3bb4
commit 2445af349d
5 files changed
+69 -7

No files matched your search

+4
View File
@@ -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
+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,