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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0d0e26f00..1de6c550d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6572,6 +6572,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,