Files
gods-eye-view/src/services/groundSnap.js
T

389 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* groundSnap.js — one-shot cached ground-height snap for MODELED grounded aircraft.
*
* Why: a parked/taxiing plane's feed altitude is last-known baro or 0 m — nowhere
* near the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland
* airports like AUS, floating ~30 m at sea-level fields like SFO, where the geoid
* sits below the ellipsoid). A 3D model placed at the meta altitude therefore
* renders inside the tiles or hovering above them. Billboards dodge this with
* disableDepthTestDistance; a depth-tested glTF model cannot.
*
* Discipline (CCTV-B9b style): scene.sampleHeight forces a synchronous offscreen
* pick render, so it is NEVER called per frame. Each grounded modeled plane gets
* ONE successful sample, cached per icao; the cache stops answering directly when
* the plane moves >MOVE_INVALIDATE_M from the sampled spot (taxiing). Misses (tiles
* still streaming, OSM fallback with no tileset skin, sample outside loaded
* geometry) are a retry-later signal with per-icao exponential backoff. A
* per-window budget stops a freshly modeled airport cluster from firing dozens of
* pick renders in one fleet tick.
*
* What a caller gets while a sample is outstanding depends on whether this
* contact has ever HAD one, and the difference is the difference between two
* kinds of wrong:
* - COLD (nothing ever resolved here) — null. There is no evidence of where the
* ground is, and the only honest answer for a depth-tested model is not to
* place it at all; the caller keeps the depth-test-free billboard.
* - WARM (a snap resolved, then taxi invalidated it and the resample has not
* landed) — the last measurement, held within HELD_SNAP_MAX_DRIFT_M. Hiding
* the model here would pop a taxiing aircraft from 3D back to 2D for a whole
* retry backoff, and the ground it was measured on a few dozen metres back is
* real evidence, not a guess. Same shape as the billboard chain's
* `_heldDisplayFloorM` (flights.js): hold the last thing that resolved,
* bounded by how far the contact can have travelled from it.
*
* A hold is a MEMORY, and it is ranked accordingly: held evidence never outranks
* fresh evidence that contradicts it. Distance travelled is only a proxy for
* "does this still describe the ground here", and it is a loose one — a contact
* can taxi 200 m onto a different surface well inside the bound, and because a
* miss preserves the hold, nothing in the retry path would ever correct that.
* So where the mesh-floor cells have actually MEASURED the surface under the
* contact's current position, and that surface is ABOVE the held value by more
* than HELD_SNAP_CONTRADICTION_M, the measurement wins: the hold is dropped and
* the contact is COLD again. The fallback is the floored 2D billboard, which is
* the honest picture — a model buried in the apron is not a better one.
*
* Shared by flights.js and militaryFlights.js (one instance each — the caches
* are per-layer, keyed by that layer's icao space).
*/
import * as Cesium from 'cesium';
/** Taxi threshold: a cached snap answers directly for moves up to this far from
* the sampled spot (m). Past it the value is demoted to a held last-known and a
* resample runs. */
const MOVE_INVALIDATE_M = 50;
/** @constant {number} How far a contact may travel from the spot its snap was
* measured at before that measurement stops describing the ground under it, and
* the hold is dropped rather than stretched.
*
* Sized to two things at once. It has to outlast a resample outage, or it buys
* nothing: RETRY_MAX_MS is 30 s of backoff, and ordinary taxi is ~10 m/s, so a
* hold that expires inside ~25 s of rolling would hand back the pop it exists to
* prevent. And it has to stay inside the grade error an airfield can hide: the
* KAUS spread this file's header cites is ~21 m across a ~4 km field, ~0.5%, so
* 250 m of taxi is ~1.3 m of vertical error — the order of the belly offsets
* already being added, and two orders under the ~100+ m burial the snap exists
* to prevent.
*
* Deliberately a quarter of the billboard chain's HELD_FLOOR_MAX_DRIFT_KM. That
* hold only ever RAISES a position (`displayFloorHeightM` never lowers), so an
* under-read there is inert; here the held value IS the placement, so the error
* shows in both directions and the bound has to be tighter. */
const HELD_SNAP_MAX_DRIFT_M = 250;
/** @constant {number} How far a held measurement may disagree with FRESH
* measured evidence at the contact's current position before it is dropped
* rather than trusted.
*
* The drift bound above asks "how far has the contact travelled?", which is a
* proxy for "does this value still describe the ground?" — and a proxy is all
* it is. 202 m of taxi onto a different surface is inside the bound and still
* wrong, and because a miss preserves the hold, repeated misses preserve the
* error for as long as they last. So the bound is not the only guard: where
* something has actually MEASURED the surface the contact is standing on now,
* that measurement outranks the memory, and a memory that contradicts it is
* discarded rather than stretched to cover the gap.
*
* 5 m sits between the two scales this has to separate:
* - ABOVE the honest disagreement between two measurements of the SAME
* surface. A floor cell is ~111 m across, so cell grade is ~0.6 m at the
* ~0.5 % KAUS spread this file's header cites and ~3.3 m even at a steep
* 3 %; the hold's own 250 m allowance is ~1.3 m at that grade. None of
* those should cost a taxiing aircraft its model.
* - BELOW the scale at which a placement reads as wrong. The belly offsets
* this snap adds are ~2–5 m and an airliner fuselage is ~4 m across, so a
* model displaced more than ~5 m is visibly off its gear rather than
* imprecisely on it — and it is two orders under the ~100+ m burial the
* snap exists to prevent.
*
* Applied in ONE direction: the hold is released only when fresh evidence sits
* ABOVE it, which is the BURIAL case — the model sunk into the surface, which
* is the entire reason this gate exists. The other sign is deliberately left
* alone, and the asymmetry is not tidiness:
*
* - A cell reading LOW against a real sample is the EXPECTED error here, not a
* contradiction. Cells are a one-shot first-write latch over ~111 m, the
* borrowed-neighbour path leans to the LOWEST on purpose (`neighborFloorM`),
* and `displayFloorHeightM` only ever RAISES — the whole floor chain is
* built to under-read rather than to invent. Releasing on that sign would
* discard good measurements wherever the two disagree downward. Measured on
* the track-regression rig: a planted cell sat 66.7 m BELOW a real sample at
* the SAME spot, and a two-sided rule dropped a hold that was correct.
* - The residual is stated rather than hidden, and stated WITH its condition.
* A contact taxiing DOWNHILL keeps a held value above its new surface and
* its model floats. The correction is a SUCCESSFUL resample, and nothing on
* this path guarantees one: on the OSM fallback sampleHeight misses forever,
* so the retries never land and the float persists for as long as the
* contact stays inside HELD_SNAP_MAX_DRIFT_M. There is no timer beside it
* and no vertical cap of its own — only whatever the ground drops within
* that radius. The other releases (a ground flip via forget(), travelling
* past the bound, a mesh cell measuring ABOVE the hold) are not ones a
* downhill taxi is guaranteed to hit either. It is accepted over the
* alternative because it errs UPWARD and stays visible, where a buried model
* errs into the terrain and cannot be seen at all. */
const HELD_SNAP_CONTRADICTION_M = 5;
/** First retry delay after a miss / tiles-not-ready (ms); doubles per consecutive miss. */
const RETRY_BASE_MS = 2000;
/** Backoff cap (ms) — on the OSM fallback (no tile skin) sampleHeight misses forever. */
const RETRY_MAX_MS = 30000;
/** Max sampleHeight calls per window across the layer (airport-cluster burst guard). */
const SAMPLE_BUDGET_PER_WINDOW = 4;
/** Budget window length (ms). */
const SAMPLE_WINDOW_MS = 250;
/**
* True when the active photoreal tileset has finished streaming the tiles in
* view (cctv.js's projectionTilesReady pattern: first Cesium3DTileset found in
* scene.primitives, duck-typed via its boolean tilesLoaded). Sampling before
* that both blows the frame budget on forced synchronous loads and produces
* misses that would burn retry backoff for nothing. With NO tileset present
* (OSM fallback) this returns true so the sample path is not permanently
* blocked — the sample just misses and backs off.
* @param {Cesium.Viewer} viewer
* @returns {boolean}
*/
function _tilesReady(viewer) {
const prims = viewer?.scene?.primitives;
if (!prims) return false;
for (let i = 0; i < prims.length; i++) {
let p = null;
try {
p = prims.get(i);
} catch {
continue;
}
if (p && p.show !== false && typeof p.tilesLoaded === 'boolean') {
return p.tilesLoaded === true;
}
}
return true;
}
/**
* Create a per-layer ground-snap cache.
* @returns {{
* heightFor: (viewer: Cesium.Viewer, icao: string, pos: Cesium.Cartesian3,
* getExclusions?: () => Array<unknown>) => number|null,
* forget: (icao: string) => void,
* clear: () => void,
* }}
*/
export function createGroundSnap({ groundFloor }) {
const { cachedMeshFloor, meshFloorPreferred, reportValidatedMeshFloorCell } =
groundFloor;
/** @type {Map<string, {h: number|null, samplePos: Cesium.Cartesian3|null,
* held: boolean, nextRetryMs: number, misses: number}>} Per-icao snap state:
* the sampled height and the surface point it was measured at, whether that
* pair is still the fresh answer or has been demoted to a bounded last-known
* (`held`), and the retry backoff a miss earned. */
const entries = new Map();
let windowStartMs = 0;
let windowCount = 0;
const scratchCarto = new Cesium.Cartographic();
const scratchSurfacePos = new Cesium.Cartesian3();
/** Separate from `scratchCarto`: the held-evidence check runs on paths that
* return BEFORE the sample writes `scratchCarto`, and on the sampling path it
* must not disturb the cartographic that `reportValidatedMeshFloorCell` reads
* back after the sample lands. */
const scratchHeldCarto = new Cesium.Cartographic();
/**
* The last measurement for a contact whose fresh snap has been demoted — it
* taxied past MOVE_INVALIDATE_M and the resample has not landed yet.
*
* Answers only while the contact is still within HELD_SNAP_MAX_DRIFT_M of the
* spot the value was measured at; past that the memory is DROPPED rather than
* stretched, and the contact is back to no evidence (null, model hidden). The
* bound is purely spatial on purpose: the ground under a contact that has not
* moved does not change with time, so what invalidates the value is the
* contact MOVING, which is exactly what this measures.
*
* @param {{h: number|null, samplePos: Cesium.Cartesian3|null, held: boolean}|undefined} entry
* @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid.
* @returns {number|null}
*/
function heldSnapM(entry, surfacePos) {
if (!entry || !entry.held || entry.h == null || !entry.samplePos)
return null;
if (
Cesium.Cartesian3.distanceSquared(surfacePos, entry.samplePos) >
HELD_SNAP_MAX_DRIFT_M * HELD_SNAP_MAX_DRIFT_M
) {
return dropHold(entry);
}
// Held memory never outranks fresh contradicting evidence. Inside the drift
// bound the contact can still have taxied onto a different surface — an
// apron edge, a ramp, the far side of a rise — and since a miss PRESERVES
// the hold, nothing else would ever correct it: the model would stay buried
// (or floating) for as long as the resample kept missing.
const fresh = freshMeasuredFloorAt(surfacePos);
if (fresh != null && fresh - entry.h > HELD_SNAP_CONTRADICTION_M) {
return dropHold(entry);
}
return entry.h;
}
/** Forget a demoted measurement: it no longer describes anywhere this contact
* is, so the contact is COLD again (model hidden, floored billboard). */
function dropHold(entry) {
entry.h = null;
entry.samplePos = null;
entry.held = false;
return null;
}
/**
* Independently measured surface height at the contact's CURRENT position, or
* null where nothing has measured there yet.
*
* MESH cells only, deliberately — not `cachedGroundFloor`. A snap is a
* `sampleHeight` of the RENDERED skin, and the mesh cells are that same
* measurement through the same validation gate (written by this module's own
* successful samples and by `meshFloorSampler.js`). `cachedGroundFloor` falls
* back to the Re:Earth DEM, which is a DIFFERENT surface: the spread between
* skin and DEM is precisely what `MESH_FLOOR_BELOW/ABOVE_PRIOR_M` budgets for
* (15 m / 80 m), so testing a snap against a DEM cell would throw away good
* holds over a disagreement that says nothing about where the model sits.
*
* The contact's OWN cell only. A neighbour is ~111 m away and may be a
* terminal roof — that is why `neighborFloorM` leans to the lowest and refuses
* to answer from a single sample — and borrowing one to DISCARD a real
* measurement would let a roof cell hide a correctly placed model.
*
* @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid.
* @returns {number|null}
*/
function freshMeasuredFloorAt(surfacePos) {
const carto = Cesium.Cartographic.fromCartesian(
surfacePos,
Cesium.Ellipsoid.WGS84,
scratchHeldCarto,
);
if (!carto) return null;
return cachedMeshFloor(
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
);
}
/**
* Cached tile-skin height (m, ellipsoid) at the aircraft's lat/lon, or null
* when this contact has NO ground evidence at all (caller keeps the model
* hidden and calls again on a later tick — cheap map hit until a retry is due).
* A contact that HAS resolved once keeps that answer through a resample outage;
* see `heldSnapM`.
*
* @param {Cesium.Viewer} viewer - Live viewer (sampling + tiles-ready check).
* @param {string} icao - Cache key (per-layer icao space).
* @param {Cesium.Cartesian3} pos - Current display position. Cache movement
* is measured after projecting this input to the WGS84 ellipsoid, so a
* poll-time altitude/datum change cannot invalidate a stationary snap.
* @param {(() => Array<unknown>)|undefined} getExclusions - Lazily builds the
* scene.sampleHeight objectsToExclude list (own billboards/models/entities —
* the vertical pick ray at a plane's OWN lat/lon lands on its icon/model
* otherwise). Only invoked when a sample actually fires.
* @returns {number|null}
*/
function heightFor(viewer, icao, pos, getExclusions) {
const surfacePos = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(
pos,
scratchSurfacePos,
);
if (!surfacePos) return null;
const cached = entries.get(icao);
if (cached && cached.h != null && cached.samplePos && !cached.held) {
if (
Cesium.Cartesian3.distanceSquared(surfacePos, cached.samplePos) <=
MOVE_INVALIDATE_M * MOVE_INVALIDATE_M
) {
return cached.h;
}
// Taxied away from the sampled spot. The measurement is DEMOTED to a
// bounded last-known rather than deleted, and the resample runs below: a
// miss now holds the aircraft on the ground it just left instead of
// hiding a model that was already up.
cached.held = true;
cached.nextRetryMs = 0;
cached.misses = 0;
}
const now = Date.now();
const entry = entries.get(icao);
if (entry && now < entry.nextRetryMs) return heldSnapM(entry, surfacePos); // backoff in force
// Per-window budget: deny WITHOUT a retry stamp so the overflow simply
// tries again next tick instead of waiting out a backoff it didn't earn.
if (now - windowStartMs > SAMPLE_WINDOW_MS) {
windowStartMs = now;
windowCount = 0;
}
if (windowCount >= SAMPLE_BUDGET_PER_WINDOW)
return heldSnapM(entry, surfacePos);
const misses = entry ? entry.misses : 0;
const miss = () => {
// A held measurement survives the miss — only the retry schedule moves.
const held = !!(
entry &&
entry.held &&
entry.h != null &&
entry.samplePos
);
const next = {
h: held ? entry.h : null,
samplePos: held ? entry.samplePos : null,
held,
nextRetryMs: now + Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** misses),
misses: misses + 1,
};
entries.set(icao, next);
return heldSnapM(next, surfacePos);
};
if (!_tilesReady(viewer)) return miss();
windowCount += 1;
let sampled;
try {
const carto = Cesium.Cartographic.fromCartesian(
pos,
Cesium.Ellipsoid.WGS84,
scratchCarto,
);
// sampleHeight throws when unsupported (no depth textures) — that's a miss.
sampled = viewer.scene.sampleHeight(
carto,
getExclusions ? getExclusions() : undefined,
);
} catch {
sampled = undefined;
}
// Same sanity floor as cctv.js's sampleGroundHeight: a hit far below the
// ellipsoid is pick garbage, not ground.
if (!Number.isFinite(sampled) || sampled < -150) return miss();
if (meshFloorPreferred()) {
reportValidatedMeshFloorCell(
Cesium.Math.toDegrees(scratchCarto.latitude),
Cesium.Math.toDegrees(scratchCarto.longitude),
sampled,
);
}
// A fresh sample RELEASES any hold: this is a measurement of where the
// contact is now, and it outranks a memory of where it was.
entries.set(icao, {
h: sampled,
samplePos: Cesium.Cartesian3.clone(surfacePos),
held: false,
nextRetryMs: 0,
misses: 0,
});
return sampled;
}
/** Drop one aircraft's snap (eviction / ground-flag flip / suppression). */
function forget(icao) {
entries.delete(icao);
}
/** Drop everything (layer destroy). */
function clear() {
entries.clear();
}
return { heightFor, forget, clear };
}