mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Import pins, infrastructure, ALPR, directions markers, bikeshare and the Bhote Koshi story use createSurfacePoints instead of their own sampling, budgets and partial triggers; ground snap, the mesh floor sampler, traffic, transit, Street Level, the camera ground guard, wildlife and the cockpit host read heights through readSurface or surfaceTriggers. Per-layer offsets and floor policies stay as options. The import no longer re-places pins on every camera move from the UI. Calls to engine.surface outside src/globe go from 41 sampling calls to 10, each a raycast, a one-shot fly-to height or an asynchronous detailed sample.
182 lines
6.5 KiB
JavaScript
182 lines
6.5 KiB
JavaScript
/**
|
|
* Camera ground guard: never leave an arrival buried in, or pressed against,
|
|
* the rendered surface.
|
|
*
|
|
* Arrival framing is computed before the destination's tiles exist, so it has
|
|
* to predict the ground height. `globe.getHeight()` answers nothing while the
|
|
* globe is hidden under photoreal tiles, curated `groundElevation` presets
|
|
* cover only a few cities, and the elevation service resolves asynchronously.
|
|
* A missing prediction used to fall back to sea level, which buried the eye by
|
|
* roughly the local elevation (Denver ~1,600 m; Austin ~170 m).
|
|
*
|
|
* After the flight lands and tiles have streamed in, this module measures the
|
|
* rendered surface under the framed target and under the camera itself, and
|
|
* lifts the eye if it sits below a usable clearance. Heading and pitch are
|
|
* held, so the correction reads as the shot settling.
|
|
*/
|
|
import { readSurface } from './globe/surfacePlacement.js';
|
|
import * as geo from './geo/index.js';
|
|
import { ownCameraArrival } from './data/cameraArrival.js';
|
|
|
|
/** Minimum eye height above the rendered surface for a usable view. */
|
|
export const MIN_EYE_CLEARANCE_M = 120;
|
|
/** Retries while tiles stream in; sampling fails until the mesh exists. */
|
|
const GUARD_ATTEMPTS = 8;
|
|
const GUARD_INTERVAL_MS = 600;
|
|
/** Ignore sub-metre noise rather than nudging the camera forever. */
|
|
const GUARD_EPSILON_M = 2;
|
|
|
|
const probeScratch = geo.cartographic.create();
|
|
|
|
/**
|
|
* Highest rendered surface across a few cells, with how many cells answered.
|
|
* A cell whose tiles are not streamed yet contributes nothing.
|
|
* @param {import('./globe/contract.js').GlobeEngine} engine Live engine.
|
|
* @param {Array<{lat: number, lon: number}>} cells Cells to probe.
|
|
* @returns {{heightM: number, sampled: number}} Reading.
|
|
*/
|
|
function sampleSurfaceM(engine, cells) {
|
|
let heightM = Number.NaN;
|
|
let sampled = 0;
|
|
for (const cell of cells) {
|
|
try {
|
|
const height = readSurface(
|
|
engine,
|
|
geo.cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
|
|
{ prefer: 'scene' },
|
|
);
|
|
if (!Number.isFinite(height)) continue;
|
|
sampled += 1;
|
|
heightM = Number.isFinite(heightM) ? Math.max(heightM, height) : height;
|
|
} catch {
|
|
/* tiles not ready for this cell */
|
|
}
|
|
}
|
|
return { heightM, sampled };
|
|
}
|
|
|
|
/**
|
|
* How far the camera must rise to clear the ground, given a measured surface.
|
|
* Pure so the policy is testable without a scene.
|
|
* @param {number} cameraHeightM Eye height above the ellipsoid.
|
|
* @param {number} groundHeightM Measured surface height above the ellipsoid.
|
|
* @param {number} [clearanceM] Desired eye height above that surface.
|
|
* @returns {number} Metres to lift; 0 when the view is already clear.
|
|
*/
|
|
export function groundClearanceDeficitM(
|
|
cameraHeightM,
|
|
groundHeightM,
|
|
clearanceM = MIN_EYE_CLEARANCE_M,
|
|
) {
|
|
if (!Number.isFinite(cameraHeightM) || !Number.isFinite(groundHeightM))
|
|
return 0;
|
|
const deficit = groundHeightM + clearanceM - cameraHeightM;
|
|
return deficit > GUARD_EPSILON_M ? deficit : 0;
|
|
}
|
|
|
|
/**
|
|
* Watch an arrival and lift the camera once the real surface can be measured.
|
|
*
|
|
* Stops quietly on a clear view, exhausted attempts, the user taking the
|
|
* controls (pointer or wheel on the canvas, a key), any camera command
|
|
* (`camera.commanded`: a flight, setView, lookAt, cancelFlight or, on
|
|
* Cesium, a new tracked entity), or `isStale()` reporting that a newer
|
|
* arrival owns the camera.
|
|
* @param {import('./globe/contract.js').GlobeEngine | null} engine
|
|
* @param {{lat: number, lon: number}} at Destination being framed.
|
|
* @param {{clearanceM?: number, attempts?: number, intervalMs?: number,
|
|
* isStale?: () => boolean, onLift?: (liftM: number) => void}} [options]
|
|
* @returns {() => void} Cancel function.
|
|
*/
|
|
export function guardCameraAboveGround(engine, at, options = {}) {
|
|
const {
|
|
clearanceM = MIN_EYE_CLEARANCE_M,
|
|
attempts = GUARD_ATTEMPTS,
|
|
intervalMs = GUARD_INTERVAL_MS,
|
|
isStale = () => false,
|
|
onLift = null,
|
|
} = options;
|
|
if (!engine || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon))
|
|
return () => {};
|
|
|
|
let timer = null;
|
|
let done = false;
|
|
const canvas = engine.host.canvas;
|
|
const documentRef = canvas?.ownerDocument;
|
|
const removers = [];
|
|
let release = () => {};
|
|
const stop = () => {
|
|
if (done) return;
|
|
done = true;
|
|
clearTimeout(timer);
|
|
for (const type of ['pointerdown', 'wheel'])
|
|
canvas?.removeEventListener?.(type, stop);
|
|
documentRef?.removeEventListener?.('keydown', stop, true);
|
|
for (const remove of removers) remove();
|
|
release();
|
|
};
|
|
release = ownCameraArrival(engine, stop);
|
|
// Any new camera owner (including duration-zero flights and, on Cesium,
|
|
// direct viewer-camera calls and tracking) yields the guard before it moves
|
|
// the eye.
|
|
removers.push(engine.camera.commanded.addEventListener(stop));
|
|
documentRef?.addEventListener?.('keydown', stop, true);
|
|
// A manual gesture ends the guard, as it interrupts camera motion elsewhere.
|
|
for (const type of ['pointerdown', 'wheel'])
|
|
canvas?.addEventListener?.(type, stop, { once: true, passive: true });
|
|
|
|
let remaining = attempts;
|
|
const attempt = () => {
|
|
if (done) return;
|
|
if (isStale() || engine.isDestroyed()) return stop();
|
|
remaining -= 1;
|
|
const { camera } = engine;
|
|
const pose = camera.pose();
|
|
const carto = pose.cartographic;
|
|
// The eye must clear both the framed target and the ground beneath it.
|
|
const below = {
|
|
lat: geo.math.toDegrees(carto.latitude),
|
|
lon: geo.math.toDegrees(carto.longitude),
|
|
};
|
|
const { heightM, sampled } = sampleSurfaceM(engine, [
|
|
{ lat: at.lat, lon: at.lon },
|
|
below,
|
|
]);
|
|
if (sampled > 0 && Number.isFinite(heightM)) {
|
|
const deficit = groundClearanceDeficitM(
|
|
carto.height,
|
|
heightM,
|
|
clearanceM,
|
|
);
|
|
if (deficit > 0) {
|
|
stop();
|
|
camera.flyTo({
|
|
destination: geo.cartesian3.fromRadians(
|
|
carto.longitude,
|
|
carto.latitude,
|
|
carto.height + deficit,
|
|
),
|
|
orientation: {
|
|
heading: pose.heading,
|
|
pitch: pose.pitch,
|
|
roll: pose.roll,
|
|
},
|
|
duration: 0.9,
|
|
easing: 'cubicInOut',
|
|
});
|
|
try {
|
|
onLift?.(deficit);
|
|
} catch {
|
|
/* observer only */
|
|
}
|
|
}
|
|
// Both samples answered: the surface is measured, nothing left to wait for.
|
|
if (sampled === 2 || deficit > 0) return stop();
|
|
}
|
|
if (remaining <= 0) return stop();
|
|
timer = setTimeout(attempt, intervalMs);
|
|
};
|
|
timer = setTimeout(attempt, intervalMs);
|
|
return stop;
|
|
}
|