refactor: own application request operations and surface caches

This commit is contained in:
Sameh Khamis committed 2026-09-14 14:49:04 -07:00
1 parent 310809e8f1
commit ac977bd8bc
53 files changed
+4388 -3317

No files matched your search

+661
View File
@@ -0,0 +1,661 @@
/** Construct an instance-owned groundFloor service with explicit dependencies. */
export function createGroundFloor({ terrain, signal }) {
const { cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrain;
signal?.throwIfAborted();
// src/data/groundFloor.js — coarse ground-floor clamp for entity render
// heights (field-test round 2026-07-06).
//
// Two owner findings drove this module:
// - RS46 (military H60): baro-only low-altitude contacts near steep terrain
// render INSIDE the hillside (no alt_geom → baro+N is off by more than the
// local relief).
// - WAKE01 (military B212): trail waypoints flagged 'ground' rendered at a
// fixed 50 m ellipsoidal sentinel — ~1.5 km underground at Kirtland AFB
// (field elevation ~1590 m ellipsoidal).
//
// The fix everywhere is the same: never render below the local ellipsoidal
// surface. This module provides the shared floor: a COARSE (3-decimal,
// ~111 m) grid over Task 3's terrainHeights resolver/cache, so that moving
// aircraft and multi-hundred-point trails collapse onto a small set of cells
// with heavy cache reuse (the exact 5-decimal grid would mint a new cache key
// every fix — the taxiing-cache lesson from the adversarial review).
// A ~111 m cell means the floor can be off by the relief within the cell in
// steep terrain; the clamp is a visual floor, not a survey — lifting an icon
// a few metres short of a ridge line still beats burying it.
//
// Zero-network on the render path: reads are synchronous cache lookups;
// `warmGroundFloor` batches the misses through resolveEllipsoidalGround
// (single-flight, fire-and-forget) exactly like flights.js's grounded-surface
// warm — results land for later polls/appends.
/** @constant {number} Metres added above the resolved floor so a clamped
* billboard/waypoint sits ON the surface instead of z-fighting it. */
const GROUND_FLOOR_LIFT_M = 1.5;
/** Accepted rendered-mesh window around a real ellipsoidal DEM prior. */
const MESH_FLOOR_BELOW_PRIOR_M = 15;
const MESH_FLOOR_ABOVE_PRIOR_M = 80;
/**
* Snaps a coordinate to the coarse (3-decimal, ~111 m) floor grid.
* @param {number} lat
* @param {number} lon
* @returns {{lat: number, lon: number}} Grid-cell coordinate.
*/
function coarseFloorCoord(lat, lon) {
return { lat: Number(lat.toFixed(3)), lon: Number(lon.toFixed(3)) };
}
/**
* Pure floor clamp.
* - finite alt + finite ground → max(alt, ground + lift)
* - finite alt + unknown ground → alt (no data, no clamp)
* - null alt + finite ground → ground + lift (the 'ground'-waypoint case:
* the point IS on the surface; put it there)
* - null alt + unknown ground → null (caller applies its own fallback)
* @param {number|null|undefined} altM - Proposed ellipsoidal render height.
* @param {number|null|undefined} groundM - Local ellipsoidal ground, if known.
* @param {number} [liftM] - Clearance above the floor.
* @returns {number|null}
*/
function floorAltitudeM(altM, groundM, liftM = GROUND_FLOOR_LIFT_M) {
const hasAlt = Number.isFinite(altM);
const hasGround = Number.isFinite(groundM);
if (hasAlt && hasGround) return Math.max(altM, groundM + liftM);
if (hasAlt) return altM;
if (hasGround) return groundM + liftM;
return null;
}
/**
* Floor decision at the point of DISPLAY (2026-08-19).
*
* `floorAltitudeM` above answers "what height should this FIX render at" and
* runs once per poll. But what the user sees is the DEAD-RECKONED position:
* a Cartesian interpolation between two fixes for the whole segment, and a
* velocity extrapolation of the newest fix while a contact coasts through its
* stale-feed grace. Both drift the rendered point horizontally away from the
* fix whose cell supplied the height — across a graded apron (KAUS spans
* ~119–140 m ellipsoidal) a taxiing sprite therefore renders under the mesh it
* has drifted over (measured −15.5 m; `scripts/qa-floor-verify.mjs`).
*
* This is the same "never below the visible surface" rule applied at the
* displayed coordinate instead of the fix coordinate. It returns `null` rather
* than the unchanged height whenever nothing should move, so the per-frame
* caller can skip rebuilding a Cartesian entirely — the common case by far.
*
* @param {number|null|undefined} displayHeightM - Ellipsoidal height the
* dead-reckoned display position currently carries.
* @param {number|null|undefined} floorM - Ellipsoidal floor at the DISPLAYED
* coordinate (`cachedGroundFloor`), or null when that cell is not warm.
* @param {number} [liftM] - Clearance above the floor.
* @returns {number|null} The height to render at, or null for "leave it alone".
*/
function displayFloorHeightM(
displayHeightM,
floorM,
liftM = GROUND_FLOOR_LIFT_M,
) {
if (!Number.isFinite(displayHeightM) || !Number.isFinite(floorM))
return null;
const lifted = floorM + liftM;
return displayHeightM < lifted ? lifted : null;
}
/** @constant {number} Pitch of the coarse floor grid in degrees — the spacing
* `coarseFloorCoord` rounds to, and therefore the step between adjacent cells. */
const FLOOR_CELL_DEG = 0.001;
/** @constant {number} Resolved neighbours required before one may be borrowed.
* A single reading cannot be checked against anything: a hangar roof and an
* apron are both "a warm cell 111 m away", and there is no way to tell them
* apart from one number. Two readings at least agree or disagree. */
const NEIGHBOR_FLOOR_MIN_SAMPLES = 2;
/**
* A floor for `cell` borrowed from the eight cells ADJACENT to it.
*
* Used only as a stand-in while `cell` itself is unresolved (2026-08-21: the
* Re:Earth proxy timed out four times in a row and a grounded contact standing
* on a cold cell rendered at the geoid — tens of metres under the mesh it was
* parked on). A neighbour is ~111 m away; on the aprons and taxiways where
* grounded contacts live that is the same surface, and it is a MEASURED height
* rather than an invented one.
*
* Leans to the LOWEST resolved neighbour, and refuses to answer at all from
* fewer than NEIGHBOR_FLOOR_MIN_SAMPLES of them.
*
* An earlier cut leaned HIGH, reasoning from the locked "never below the
* visible surface" principle. That principle is about a contact's OWN measured
* ground; applied to a BORROWED cell it inverts, and an owner playtest found
* why — planes floating in midair at terminal gates. The two errors are not
* symmetric:
* - Too LOW is inert. `displayFloorHeightM` only ever RAISES a position, so a
* floor under the contact simply does not lift it — the same "no clamp" the
* cold-cell case already has, bounded by one cell of grade (metres).
* - Too HIGH actively invents a position. It is bounded by BUILDING HEIGHT,
* not by grade, and a parked contact holds it: measured on the A/B rig,
* a lone roof neighbour put a contact 29.5 m up and left it there
* permanently when its own cell never warmed, and 6.3 s of visible
* stair-stepping when it did.
* A plane at a gate is on the apron, never on the terminal roof, so on a
* structure edge the LOW neighbour is the answer and the high one is an
* artefact of the photogrammetric mesh.
*
* The honest residual: on a genuine SLOPE the lowest neighbour under-reads by
* the grade across ~111 m, so the clamp lifts a little less than it could. That
* shows up as no lift rather than as a wrong one, and the contact's own cell
* corrects it as soon as it warms.
*
* @param {{lat: number, lon: number}|null|undefined} cell - Coarse cell.
* @returns {number|null} Ellipsoidal floor, or null when too few neighbours are
* warm to borrow from.
*/
function neighborFloorM(cell) {
if (!Number.isFinite(cell?.lat) || !Number.isFinite(cell?.lon)) return null;
let lowest = null;
let resolved = 0;
for (let dLat = -1; dLat <= 1; dLat += 1) {
for (let dLon = -1; dLon <= 1; dLon += 1) {
if (dLat === 0 && dLon === 0) continue;
const h = cachedGroundFloor(
cell.lat + dLat * FLOOR_CELL_DEG,
cell.lon + dLon * FLOOR_CELL_DEG,
);
if (h == null) continue;
resolved += 1;
if (lowest == null || h < lowest) lowest = h;
}
}
return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;
}
/** @constant {number} Cap on cells one corridor may contribute — a fast rollout
* or a spliced fix must not turn one contact into an unbounded warm batch.
* Sized for the worst realistic ground segment: a 27 m/s rollout coasts ~810 m
* (≈7 cells) in one poll interval, leaving headroom plus the endpoint slot. */
const CORRIDOR_MAX_CELLS = 12;
/** @constant {number} Step along a corridor leg when collecting cells (~14 m,
* an eighth of a cell). Half-cell steps were only cell-complete on LONG legs:
* a short leg rounds to a single step, so the walk degenerates to testing the
* leg's endpoints and a cell clipped between them is skipped. At this step any
* cell the path occupies for ~14 m of ground or more contains a sample — the
* guarantee this walk actually makes. The loop still exits the moment the cell
* budget fills, so a finer step costs iterations only on short corridors. */
const CORRIDOR_WALK_STEP_DEG = 0.000125;
/** @constant {number} Degrees added around a cell before a contact is allowed
* to adopt the next one (~22 m). A dead-reckoned position that jitters across
* a 0.001° edge would otherwise alternate floors at fleet-tick rate and pop
* the sprite between two heights. */
const CELL_HYSTERESIS_DEG = 0.0002;
/**
* The floor cell a contact should read, with boundary hysteresis.
*
* Cells are 0.001° squares, so a display position sitting on an edge (parked
* GPS jitter, the tracked reconciliation's decaying correction) flips between
* two cells frame to frame — and their floors can differ by the local grade or,
* next to a building, several metres. The contact keeps its previous cell until
* it is CELL_HYSTERESIS_DEG clear of that cell's bounds, which costs two
* comparisons and no allocation on the sticky path.
*
* @param {number} lat @param {number} lon - Displayed coordinate.
* @param {{lat: number, lon: number}|null|undefined} previousCell - The cell
* this contact read last tick, if any.
* @returns {{lat: number, lon: number}} The cell to read.
*/
function stickyFloorCell(lat, lon, previousCell) {
if (
previousCell &&
Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG &&
Math.abs(lon - previousCell.lon) <= 0.0005 + CELL_HYSTERESIS_DEG
) {
return previousCell;
}
return coarseFloorCoord(lat, lon);
}
/**
* The coarse cells a contact's DISPLAY is about to walk through.
*
* The dead-reckoned display runs one render delay behind and advances toward
* the newest fix, so the segment [display → newest fix] is exactly the ground
* it will render over during the next poll or two. Warming only the cell it is
* standing on warms the cell it is LEAVING: at 10 m/s a contact crosses a
* ~111 m cell every ~11 s while the warm batch runs once per 30 s poll, so it
* stays permanently ahead of its own floor data and the display clamp has
* nothing to read (KAUS: sprites 2–4 m under the mesh with a cold cell for the
* whole observation).
*
* Walks in half-cell steps so no cell between the ends is skipped. The step
* COUNT follows the segment length (never a fixed cap — a capped count over a
* long segment samples sparsely and leaves holes in the middle of ground the
* contact does cross). Length is bounded instead, and honestly: the walk emits
* a GAP-FREE PREFIX from `from`, and the destination cell always takes the last
* slot. A corridor longer than CORRIDOR_MAX_CELLS is therefore truncated —
* contiguous near ground plus the far endpoint — rather than thinned; the
* ground in between is picked up on a later poll as the display advances into
* it. Loop work stays bounded because the walk exits as soon as the cell budget
* fills, whatever the segment length.
*
* Takes the display PATH as a polyline, not two endpoints: while the contact
* is extrapolating, that path is an arc (the dead-reckon integrates a
* constant-rate turn), and walking a straight chord between its ends would
* warm ground the contact never crosses while its actual arc stayed cold.
*
* @param {Array<{lat: number, lon: number}>} points - Display path, current
* position first, destination last.
* @returns {Array<{lat: number, lon: number}>} Deduped coarse cells, start
* first, destination last.
*/
function corridorFloorCells(points) {
if (!Array.isArray(points) || !points.length) return [];
const first = points[0];
if (!Number.isFinite(first?.lat) || !Number.isFinite(first?.lon)) return [];
const start = coarseFloorCoord(first.lat, first.lon);
const out = [start];
const seen = new Set([`${start.lat},${start.lon}`]);
let destination = null;
for (let i = points.length - 1; i >= 1; i--) {
if (Number.isFinite(points[i]?.lat) && Number.isFinite(points[i]?.lon)) {
destination = coarseFloorCoord(points[i].lat, points[i].lon);
break;
}
}
if (!destination) return out;
const destKey = `${destination.lat},${destination.lon}`;
// One slot is reserved for the destination, appended after the prefix.
const prefixLimit = CORRIDOR_MAX_CELLS - 1;
walk: for (let s = 0; s < points.length - 1; s++) {
const a = points[s];
const b = points[s + 1];
if (![a?.lat, a?.lon, b?.lat, b?.lon].every(Number.isFinite)) continue;
const steps = Math.ceil(
Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lon - a.lon)) /
CORRIDOR_WALK_STEP_DEG,
);
for (let i = 1; i <= steps; i++) {
if (out.length >= prefixLimit) break walk;
const t = i / steps;
const cell = coarseFloorCoord(
a.lat + (b.lat - a.lat) * t,
a.lon + (b.lon - a.lon) * t,
);
const key = `${cell.lat},${cell.lon}`;
if (seen.has(key) || key === destKey) continue;
seen.add(key);
out.push(cell);
}
}
if (!seen.has(destKey)) out.push(destination);
return out;
}
/**
* Shares one poll's corridor-cell budget across the contacts that need it.
*
* The first cut walked contacts in Map insertion order and decremented the
* budget BEFORE deduping, so parked contacts burned slots on cells the poll had
* already collected and the contacts actually outrunning their floor data
* starved. Three rules fix that:
* 1. Cells already collected are FREE — a parked contact, whose corridor is
* its own fix cell, never competes for budget at all.
* 2. Candidates are ordered by how many of their cells are genuinely cold,
* then by speed (whoever leaves their floor behind soonest).
* 3. Allocation runs in widening rounds — one cell each, then `fairShare`
* each, then the remainder — so no contact is left with NOTHING while
* another takes seconds. The nearest cell is the one a contact needs first
* anyway; the far end of its corridor can wait a poll.
*
* @param {Array<{cells: Array<{lat: number, lon: number}>, cold: number, speedMps: number}>} candidates
* @param {Set<string>} seen - Keys ("lat,lon") already collected; MUTATED with
* everything this call allocates, so the caller's dedupe stays consistent.
* Ranking alone still starves a TIE longer than the budget: 80 equally needy
* movers means the same stable prefix wins every poll and the tail never moves.
* The `epoch` rotates each run of equal-ranked candidates, so a tie cycles
* round-robin across polls while a genuinely needier contact is never demoted
* below a less needy one (rotation happens WITHIN a rank, never across ranks).
*
* `seen` alone is NOT a sufficient free-list: in production it holds only the
* CURRENT poll's batch, so a cell that warmed on an earlier poll looked new and
* spent budget again, letting a contact's near cells consume its whole share
* poll after poll while the ground ahead of it stayed cold. Budget is therefore
* charged only for cells with no floor yet (`isWarm`) — but warm cells are
* still EMITTED, because the mesh sampler must see a cell again after its DEM
* prior lands or it never latches a rendered-surface height there.
*
* That gives a service bound derivable from the policy rather than a magic
* number: the first round charges at most one cell per candidate, so at least
* `min(candidates, budget)` candidates are served per poll; a candidate that
* misses out keeps its cold count while every served candidate's drops, so it
* outranks them next poll. Every candidate is therefore served within
* `ceil(candidates / budget)` polls once the warms land.
*
* @param {number} budget - Max cold cells to charge for.
* @param {number} fairShare - Per-candidate cap for the middle round.
* @param {number} [epoch] - Poll counter; rotates equal-ranked runs.
* @param {(cell: {lat: number, lon: number}) => boolean} [isWarm] - Whether a
* cell already has a floor (defaults to the shared cache).
* @returns {Array<{lat: number, lon: number}>} Cells to add, allocation order.
*/
function allocateCorridorCells(
candidates,
seen,
budget,
fairShare,
epoch = 0,
isWarm = (cell) => cachedGroundFloor(cell.lat, cell.lon) != null,
) {
const out = [];
if (!Array.isArray(candidates) || !candidates.length || !(budget > 0))
return out;
const ranked = [...candidates].sort(
(a, b) => b.cold - a.cold || b.speedMps - a.speedMps,
);
if (Number.isFinite(epoch) && epoch !== 0) {
for (let i = 0; i < ranked.length;) {
let j = i + 1;
while (
j < ranked.length &&
ranked[j].cold === ranked[i].cold &&
ranked[j].speedMps === ranked[i].speedMps
)
j += 1;
const runLength = j - i;
if (runLength > 1) {
const shift = ((epoch % runLength) + runLength) % runLength;
const run = ranked.slice(i, j);
for (let m = 0; m < runLength; m += 1)
ranked[i + m] = run[(m + shift) % runLength];
}
i = j;
}
}
// Cursor per candidate, carried ACROSS rounds: a later round resumes where
// the previous one stopped instead of re-walking cells already handled.
const cursor = new Array(ranked.length).fill(0);
let left = budget;
const round = (limit) => {
for (let i = 0; i < ranked.length; i++) {
const cells = ranked[i].cells || [];
while (cursor[i] < cells.length && cursor[i] < limit) {
const cell = cells[cursor[i]];
const key = `${cell.lat},${cell.lon}`;
if (seen.has(key)) {
cursor[i] += 1;
continue;
} // already in this batch
if (isWarm(cell)) {
// Free: it has a floor already. Still emitted, so the mesh sampler
// gets another look now that its DEM prior exists.
cursor[i] += 1;
seen.add(key);
out.push(cell);
continue;
}
if (left <= 0) return;
cursor[i] += 1;
seen.add(key);
out.push(cell);
left -= 1;
}
}
};
round(1); // nobody goes home empty-handed
round(fairShare); // then a fair slice each
round(CORRIDOR_MAX_CELLS); // then the remainder, neediest first
return out;
}
// --- Mesh-floor cells (round 4, owner-approved design) ---------------------
// The Re:Earth DEM is BARE EARTH; the visible world in the google-3d regime
// is the photogrammetric MESH, which sits above it (measured ~17 m at the
// Austin airport apron). DEM-flooring therefore still buried sprites/trails
// inside the mesh. These cells hold the RENDERED surface height, sampled
// one-shot per coarse cell near the viewer (scene.sampleHeight — the same
// source groundSnap uses, which is why 3D models always looked right) and
// reported here by meshFloorSampler.js. Design principle (owner): NEVER
// below the visible surface; slightly above is always fine. The DEM stays
// the instant global prior/fallback and the sanity gate for samples.
/** @type {Map<string, number>} coarse cell key -> rendered-surface height (m, ellipsoidal). */
const _meshCells = new Map();
/** @type {boolean} Whether mesh cells apply — true only in the google-3d
* regime (photoreal stack). On globe stacks the rendered surface IS the
* terrain provider (Re:Earth on keyless), so the DEM is already correct
* there and mesh heights would float. Kept current by meshFloorSampler's
* 'gev:map-stack-changed' listener. Photoreal is the boot default. */
let _meshPreferred = true;
/** @param {boolean} preferred - google-3d regime active. */
function setMeshFloorPreferred(preferred) {
_meshPreferred = !!preferred;
}
/** @returns {boolean} Whether mesh-floor cells currently apply. */
function meshFloorPreferred() {
return _meshPreferred;
}
/**
* Records a sampled rendered-surface height for the cell containing the
* coordinate (one-shot latch — first accepted sample wins).
* @param {number} lat @param {number} lon @param {number} heightM
*/
function reportMeshFloorCell(lat, lon, heightM) {
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
!Number.isFinite(heightM)
)
return;
const c = coarseFloorCoord(lat, lon);
const key = `${c.lat},${c.lon}`;
if (!_meshCells.has(key)) _meshCells.set(key, heightM);
}
/** Return whether a rendered-mesh sample is plausible against a real DEM prior. */
function meshFloorSampleWithinPrior(heightM, priorM) {
return (
Number.isFinite(heightM) &&
Number.isFinite(priorM) &&
heightM >= priorM - MESH_FLOOR_BELOW_PRIOR_M &&
heightM <= priorM + MESH_FLOOR_ABOVE_PRIOR_M
);
}
/**
* Validate and record a rendered-mesh floor through the single shared gate.
* Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD,
* rooftop, or aircraft hit would otherwise win the session-long first-write
* latch and poison every floor consumer in the cell.
* @returns {boolean} Whether the sample passed and was reported.
*/
function reportValidatedMeshFloorCell(lat, lon, heightM) {
if (![lat, lon, heightM].every(Number.isFinite)) return false;
const cell = coarseFloorCoord(lat, lon);
const prior = cachedRealEllipsoidalGround(cell.lat, cell.lon);
if (!meshFloorSampleWithinPrior(heightM, prior)) return false;
reportMeshFloorCell(cell.lat, cell.lon, heightM);
return true;
}
/**
* Synchronous mesh-cell read — null when the cell is unsampled OR the
* google-3d regime is inactive (globe stacks must floor on the DEM).
* @param {number} lat @param {number} lon
* @returns {number|null}
*/
function cachedMeshFloor(lat, lon) {
if (!_meshPreferred) return null;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const c = coarseFloorCoord(lat, lon);
const h = _meshCells.get(`${c.lat},${c.lon}`);
return h != null ? h : null;
}
/** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */
function _clearMeshFloorCellsForTest() {
_meshCells.clear();
}
/**
* Synchronous coarse-cell ground read (warm cache only — never a fetch).
* Prefers the RENDERED-surface mesh cell (google-3d regime) and falls back
* to the Re:Earth DEM cell — the single choke point every ground-adjacent
* consumer (fleet clamp, grounded surface chain, trail floors) reads, so
* they all agree on one surface.
* @param {number} lat
* @param {number} lon
* @returns {number|null} Ellipsoidal floor of the cell, or null if not warm.
*/
function cachedGroundFloor(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const mesh = cachedMeshFloor(lat, lon);
if (mesh != null) return mesh;
const c = coarseFloorCoord(lat, lon);
return cachedRealEllipsoidalGround(c.lat, c.lon);
}
/**
* Awaitable variant for one-shot consumers (trail backfills): dedupes the
* points to unique coarse cells and resolves them, so `cachedGroundFloor`
* reads immediately after are warm. The underlying resolver chunks (≤200/req)
* and falls back per-point, so any trace length is safe. Never throws.
* @param {Array<{lat: number, lon: number}>} points
* @returns {Promise<void>}
*/
/** @constant {number} Max ms a one-shot consumer (trail backfill) waits for
* the floor resolve before painting with whatever cells are warm. Owner
* field test 2026-07-06 round 2: an UNBOUNDED await here held trail paints
* hostage to cold Re:Earth lookups spanning a whole flight path (seconds to
* the 30 s proxy timeout) — trails showed up late, short, or not at all.
* The resolve keeps running past the deadline and lands in the cache for
* the next paint/select. */
const FLOOR_RESOLVE_DEADLINE_MS = 1200;
/**
* Bounded-latency variant of resolveGroundFloorCells: waits at most
* FLOOR_RESOLVE_DEADLINE_MS, then returns so the caller can paint with the
* cells that ARE warm. The underlying resolve continues in the background
* and fills the cache for later reads. Never throws.
* @param {Array<{lat: number, lon: number}>} points
* @returns {Promise<void>}
*/
function resolveGroundFloorCellsBounded(points) {
return Promise.race([
resolveGroundFloorCells(points),
new Promise((resolve) => setTimeout(resolve, FLOOR_RESOLVE_DEADLINE_MS)),
]);
}
async function resolveGroundFloorCells(points) {
if (!Array.isArray(points) || !points.length) return;
const cells = new Map();
for (const p of points) {
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
const c = coarseFloorCoord(p.lat, p.lon);
const key = `${c.lat},${c.lon}`;
if (cells.has(key)) continue;
if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue;
cells.set(key, c);
}
if (!cells.size) return;
try {
await resolveEllipsoidalGround([...cells.values()]);
} catch {
/* best-effort — unresolved cells simply don't clamp */
}
}
/** @type {boolean} Single-flight guard for the warm batch. */
let _floorBatchInFlight = false;
/** @type {Map<string, {lat: number, lon: number}>} Cells requested while a
* batch was in flight — flushed as the NEXT batch the moment it completes.
* Round 6: the old guard silently DROPPED contending calls (flights every
* 30 s, military every 15 s, trail backfills ad hoc), so arriving at a new
* airport could take minutes of lost polls before a given cell resolved —
* the owner's "you can't just go to a place and start inspecting it". */
const _pendingFloorCells = new Map();
/** Kicks the resolver for a cell map, chaining any cells queued meanwhile. */
function _resolveFloorCells(cells) {
_floorBatchInFlight = true;
resolveEllipsoidalGround([...cells.values()])
.catch(() => {
/* best-effort — unclamped this tick, floor lands later */
})
.finally(() => {
_floorBatchInFlight = false;
if (!signal?.aborted && _pendingFloorCells.size) {
const next = new Map(_pendingFloorCells);
_pendingFloorCells.clear();
_resolveFloorCells(next);
}
});
}
/**
* Fire-and-forget batch warm of the coarse floor cells for the given points.
* Dedupes to unique cells, skips already-warm ones, and never blocks or
* throws into the caller. Contending calls QUEUE (never drop): their cells
* ride the next batch as soon as the in-flight one completes. Results are
* picked up by later `cachedGroundFloor` reads.
* @param {Array<{lat: number, lon: number}>} points
*/
function warmGroundFloor(points) {
if (signal?.aborted) return;
if (!Array.isArray(points) || !points.length) return;
const cells = new Map();
for (const p of points) {
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
const c = coarseFloorCoord(p.lat, p.lon);
const key = `${c.lat},${c.lon}`;
if (cells.has(key) || _pendingFloorCells.has(key)) continue;
if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue; // already warm
cells.set(key, c);
}
if (!cells.size) return;
if (_floorBatchInFlight) {
for (const [key, c] of cells) _pendingFloorCells.set(key, c);
return;
}
_resolveFloorCells(cells);
}
signal?.addEventListener(
'abort',
() => {
_meshCells.clear();
_pendingFloorCells.clear();
},
{ once: true },
);
return {
GROUND_FLOOR_LIFT_M,
MESH_FLOOR_BELOW_PRIOR_M,
MESH_FLOOR_ABOVE_PRIOR_M,
coarseFloorCoord,
floorAltitudeM,
displayFloorHeightM,
FLOOR_CELL_DEG,
NEIGHBOR_FLOOR_MIN_SAMPLES,
neighborFloorM,
CORRIDOR_MAX_CELLS,
CORRIDOR_WALK_STEP_DEG,
CELL_HYSTERESIS_DEG,
stickyFloorCell,
corridorFloorCells,
allocateCorridorCells,
setMeshFloorPreferred,
meshFloorPreferred,
reportMeshFloorCell,
meshFloorSampleWithinPrior,
reportValidatedMeshFloorCell,
cachedMeshFloor,
_clearMeshFloorCellsForTest,
cachedGroundFloor,
FLOOR_RESOLVE_DEADLINE_MS,
resolveGroundFloorCellsBounded,
resolveGroundFloorCells,
warmGroundFloor,
};
}
+388
View File
@@ -0,0 +1,388 @@
/**
* 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 };
}
+199
View File
@@ -0,0 +1,199 @@
import * as Cesium from 'cesium';
/** Construct an instance-owned meshFloorSampler service with explicit dependencies. */
export function createMeshFloorSampler({
groundFloor,
signal,
eventTarget = null,
}) {
const {
coarseFloorCoord,
cachedMeshFloor,
reportValidatedMeshFloorCell,
setMeshFloorPreferred,
meshFloorPreferred,
cachedGroundFloor,
} = groundFloor;
signal?.throwIfAborted();
// src/data/meshFloorSampler.js — rendered-surface (mesh) floor sampling
// (field-test round 4, 2026-07-06).
//
// The visible world in the google-3d regime is the PHOTOGRAMMETRIC MESH,
// which sits above the Re:Earth bare-earth DEM (measured ~17 m at the Austin
// airport apron) — so DEM-floored sprites/trails still buried themselves in
// the mesh while 3D models (which groundSnap against the mesh) looked right.
// This module samples the mesh height ONE-SHOT per coarse cell and reports it
// to groundFloor.js, whose cachedGroundFloor() then answers mesh-first.
//
// Rationing (the CCTV loading-jitter lesson): scene.sampleHeight forces
// tile-load prioritization at the probe point, so sampling is (a) gated to
// cells NEAR the viewer — where tiles are streamed and where an error is
// visible at all, (b) capped per call, (c) one-shot per cell for the session,
// (d) skipped entirely outside the google-3d regime. A returned-undefined
// probe (tiles not streamed yet) is NOT latched — it retries on a later call.
//
// Sample acceptance is sanity-gated against the DEM prior when warm (a
// vertical probe can hit a rooftop or another aircraft instead of pavement):
// the mesh legitimately sits ABOVE bare earth, so the window is asymmetric.
/** @constant {number} Max scene samples per call (one call per layer poll). */
const MAX_SAMPLES_PER_CALL = 40;
/** @constant {number} Only cells within this range of the viewer subpoint are
* sampled. Round-5 hardening: tightened 40 → 15 km — beyond the streamed
* fine-LOD area a probe returns COARSE-tile heights (not undefined), and the
* one-shot latch made those permanent (owner round 5: grounded planes stuck
* at a uniform wrong height). */
const MAX_SAMPLE_DIST_KM = 15;
/** @constant {number} No sampling when the camera is above this height — the
* streamed LOD under a high camera is coarse everywhere, so every probe
* would latch junk. */
const MAX_CAMERA_HEIGHT_M = 25_000;
/** @type {Cesium.Cartographic} Scratch for probe coordinates. */
const _scratchProbe = new Cesium.Cartographic();
// Regime tracking: mesh cells only apply while the photoreal (google-3d)
// stack renders. main.js re-dispatches MapStackController.onChange as this
// CustomEvent; the boot default is photoreal, matching groundFloor's initial
// preference. Module-scope listener: the module only loads in the browser
// bundle, and the subscription is idempotent for the app's lifetime.
/**
* True when a VISIBLE 3D tileset in the scene reports its streaming queue
* drained (tilesLoaded) — the mirror of cctv.js's projectionTilesReady.
* Walks top-level primitives only (a handful; once per poll).
* @param {Cesium.Scene} scene
* @returns {boolean}
*/
function _visibleTilesetLoaded(scene) {
try {
const prims = scene.primitives;
for (let i = 0; i < prims.length; i++) {
const p = prims.get(i);
if (p instanceof Cesium.Cesium3DTileset && p.show) {
return !!p.tilesLoaded;
}
}
} catch {
/* mid-teardown */
}
return false;
}
/**
* Equirectangular distance (km) — same approximation the flights clamp uses.
*/
function _approxKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
const dLon =
(lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
/**
* Samples the rendered mesh height for the coarse cells containing `points`,
* one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per
* layer poll (never per contact, never per frame). Synchronous — Cesium's
* sampleHeight is a CPU-side query against loaded tiles.
*
* @param {Cesium.Scene|undefined} scene - The scene (skipped when absent/torn down).
* @param {Array<{lat: number, lon: number}>} points - Contact/waypoint coords.
* @param {object} [options]
* @param {Array<object>} [options.excludeObjects] - Own billboards/models to
* exclude from the probe (Cesium matches instances/.primitive/.id — pass
* Billboard and Model instances, NOT collections).
* @param {number} [options.viewerLat] @param {number} [options.viewerLon]
* Viewer subpoint (computed once by the caller's poll).
*/
function sampleMeshFloorCells(
scene,
points,
{ excludeObjects = [], viewerLat, viewerLon } = {},
) {
if (!meshFloorPreferred()) return;
if (!scene || typeof scene.sampleHeight !== 'function') return;
if (!Array.isArray(points) || !points.length) return;
// Round-5 hardening (the CCTV projectionTilesReady lesson, ported late):
// while tiles are MID-STREAM a probe returns coarse-LOD heights — real
// numbers, wildly wrong — and the one-shot latch made them permanent.
// Only sample when the visible Google tileset reports tilesLoaded AND the
// camera is low enough that the streamed LOD near it is fine-grained.
const camH = scene.camera?.positionCartographic?.height;
if (!Number.isFinite(camH) || camH > MAX_CAMERA_HEIGHT_M) return;
if (!_visibleTilesetLoaded(scene)) return;
let sampled = 0;
const attempted = new Set();
for (const p of points) {
if (sampled >= MAX_SAMPLES_PER_CALL) break;
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
const cell = coarseFloorCoord(p.lat, p.lon);
const key = `${cell.lat},${cell.lon}`;
if (attempted.has(key)) continue;
attempted.add(key);
if (cachedMeshFloor(cell.lat, cell.lon) != null) continue; // one-shot latch
if (
Number.isFinite(viewerLat) &&
Number.isFinite(viewerLon) &&
_approxKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM
) {
continue; // too far: tiles not streamed there, probe would be a guaranteed miss
}
let height;
try {
const carto = Cesium.Cartographic.fromDegrees(
cell.lon,
cell.lat,
0,
_scratchProbe,
);
height = scene.sampleHeight(carto, excludeObjects);
sampled += 1;
} catch {
continue; // scene mid-teardown — try again next poll
}
if (!Number.isFinite(height)) continue; // tiles not loaded yet — no negative latch
// Round 5: a REAL Re:Earth prior is REQUIRED before any sample latches.
// - A geoid-fallback prior (cached while the proxy was failing) is a
// sea-level poison that rejected legitimate samples at elevated
// fields (AUS: mesh 138 m vs poisoned −27 m prior → dropped forever).
// - No prior at all means no way to tell a real surface from a
// coarse-LOD/rooftop/aircraft mis-hit — the old sanity-only branch
// latched a 37 m "surface" at Austin permanently. The DEM lands
// within a poll (the same warm batch that queued this cell), so
// waiting costs one poll, not correctness.
// 2026-08-21: a "provisional" tier that KEPT the reading when no prior
// existed was built here and then removed, because it was measured failing.
// Run twice against the real GPU at the same Austin apron with the proxy
// down, it recorded 122.1 m once and 20.6 m the next time — the second is a
// coarse-LOD read of ground that is really ~122 m, and it left the contact
// ~100 m under the mesh. The DEM requirement above is not a formality.
//
// Before reaching for the obvious gate: `tilesLoaded` / `allTilesLoaded` is
// NOT a resolution signal. It reports that the streaming queue has drained
// for the CURRENT camera's screen-space-error target, and a distant or high
// camera is entirely satisfied by coarse tiles — which is exactly the state
// that reported "loaded" during the measurement above. Gating harder on it
// buys nothing. A correct gate has to ask about the specific tile covering
// the sample point: walk the tileset tree to the tile containing the
// coordinate and require its `geometricError` to be below a threshold tied
// to the accuracy the caller needs, before trusting the sample. That is
// feasible and deliberately deferred — see the P2 in
// the project roadmap, which bundles it with the fly_route probe so
// one post-launch pass settles rendered-mesh sampling everywhere.
reportValidatedMeshFloorCell(cell.lat, cell.lon, height);
}
}
// re-export for callers that want one import site
const onStack = (event) => {
const id = event?.detail?.activeId;
if (id) setMeshFloorPreferred(id === 'photoreal');
};
eventTarget?.addEventListener('gev:map-stack-changed', onStack);
signal?.addEventListener(
'abort',
() => eventTarget?.removeEventListener('gev:map-stack-changed', onStack),
{ once: true },
);
return { sampleMeshFloorCells, cachedGroundFloor };
}
+276
View File
@@ -0,0 +1,276 @@
import { ensureGeoidReady, geoidHeight } from '../data/geoid.js';
/** Construct an instance-owned terrainHeights service with explicit dependencies. */
export function createTerrainHeights({ source, signal }) {
if (typeof source?.getHeights !== 'function')
throw new TypeError('Terrain heights require a source');
signal?.throwIfAborted();
// src/data/terrainHeights.js — batched, cached client terrain-height resolver
// (docs/plans/2026-07-05-entity-height-datum-fix.md Task 3).
//
// Resolves ELLIPSOIDAL ground height per (lat, lon) via the server-side
// `/api/terrain/heights` proxy (Task 2 — Re:Earth `heights.json`, disk-cached,
// serve-stale). When the proxy is unreachable (or errors) AND the point isn't
// already warm in this module's in-memory cache, falls back to bundled-geoid
// math (Task 1, `src/data/geoid.js`):
//
// sourceOrthometricM (if finite) + geoidHeight(lat, lon) → source:'geoid-fallback'
// else geoidHeight(lat, lon) alone (H≈0 "at the geoid/coast" prior) → source:'geoid-fallback'
//
// Two things learned building the Task 2 proxy (see its report, and the
// ledger's T2 entry) that this module builds in:
//
// 1. The proxy's GET has a Node http header-size ceiling — empirically a
// single request much past ~700-1500 points (depending on coordinate
// precision) risks a raw socket-level 431 *before* the proxy's own
// request handler runs. So every network request this module issues is
// chunked at CHUNK_SIZE (64 points), well under that ceiling, and
// chunks are sent SEQUENTIALLY (not in parallel) to keep this client
// well-behaved against a single dev-server proxy.
// 2. The proxy's disk cache is keyed by the raw `points` query string, with
// no eviction — so a client that varies coordinate precision or point
// order defeats the warm cache and grows the cache file unbounded. This
// module rounds every coordinate to 5 decimal places (~1.1 m of
// precision at the equator — comfortably tighter than any of this app's
// placement needs) BOTH for its own in-memory cache key AND for the
// points string sent to the proxy, so repeated calls (including calls
// from a fresh page load, a different camera batch, etc.) consistently
// hit the same warm proxy cache entry.
/** Max points per outgoing request to `/api/terrain/heights` (see file header, point 1). */
// Match the server's upstream batch so sequential upstream work also fits
// within this client's 30-second request deadline when Re:Earth slows.
const CHUNK_SIZE = 64;
/** Avoid repeatedly hitting a known-failing proxy from warm fallback reads. */
const GEOID_FALLBACK_COOLDOWN_MS = 60_000;
/**
* In-memory cache: `"lat.toFixed(5),lon.toFixed(5)"` -> `{ellipsoid, source}`.
* Module-scoped (not exported) — the only reads are through
* `cachedEllipsoidalGround` and the internal lookup in
* `resolveEllipsoidalGround`.
* @type {Map<string, {ellipsoid: number, source: 'reearth'|'geoid-fallback', retryAt?: number}>}
*/
const cache = new Map();
/**
* Builds the rounded cache key shared between the in-memory cache and the
* `points` string sent to the proxy (see file header, point 2).
* @param {number} lat
* @param {number} lon
* @returns {string}
*/
function cacheKey(lat, lon) {
return `${lat.toFixed(5)},${lon.toFixed(5)}`;
}
/**
* Synchronous read of a previously resolved ellipsoidal ground height.
* Returns null if this exact (rounded) coordinate hasn't been resolved yet
* by either the proxy path or the geoid-fallback path.
* @param {number} lat
* @param {number} lon
* @returns {number|null}
*/
function cachedEllipsoidalGround(lat, lon) {
const entry = cache.get(cacheKey(lat, lon));
return entry ? entry.ellipsoid : null;
}
/**
* Field-test round 5 (2026-07-06, the "sea-level poison"): like
* cachedEllipsoidalGround but returns null for geoid-FALLBACK entries —
* only a real Re:Earth value counts. A fallback (cached when the proxy
* failed mid-burst) is the geoid surface, which at Austin sits ~165 m below
* the airport: floors built on it sank every sprite/trail, and the mesh
* sampler's sanity gate rejected REAL surface samples against it. Floor
* consumers read THIS; the plain read stays for display-only consumers.
* @param {number} lat
* @param {number} lon
* @returns {number|null}
*/
function cachedRealEllipsoidalGround(lat, lon) {
const entry = cache.get(cacheKey(lat, lon));
return entry && entry.source === 'reearth' ? entry.ellipsoid : null;
}
/**
* Fetches one chunk (<=CHUNK_SIZE points) from the `/api/terrain/heights`
* proxy. Returns a Map keyed by the same rounded cache key so callers can
* look results up positionally-independent of upstream response ordering.
* Throws on any failure (non-ok response, network error, malformed body) —
* callers decide the fallback behavior per point.
* @param {Array<{key: string, lat: number, lon: number}>} chunk
* @returns {Promise<Map<string, number>>} key -> ellipsoid height (m)
*/
async function fetchChunk(chunk) {
// lon,lat order (matches the proxy's documented `points=lon,lat;…` contract
// and Task 2's implementation).
const results = await source.getHeights(chunk, {
signal: signal
? AbortSignal.any([signal, AbortSignal.timeout(30000)])
: AbortSignal.timeout(30000),
});
const body = { results };
if (!Array.isArray(body?.results))
throw new Error('malformed terrain heights response (no results array)');
if (body.results.length !== chunk.length) {
throw new Error(
`terrain heights response length mismatch (expected ${chunk.length}, got ${body.results.length})`,
);
}
const out = new Map();
// The proxy is documented to preserve input order (Task 2 report verifies
// this against the live upstream), so map positionally rather than trust
// the response's own lon/lat fields to re-match — those are also present
// for callers who want them, but this module doesn't need them.
for (let i = 0; i < chunk.length; i += 1) {
const ellipsoid = body.results[i]?.ellipsoid;
if (!Number.isFinite(ellipsoid))
throw new Error(`non-finite ellipsoid height at index ${i}`);
out.set(chunk[i].key, ellipsoid);
}
return out;
}
/**
* Computes the geoid-fallback ellipsoidal height for one point per the
* brief's fallback chain: `sourceOrthometricM + geoidHeight` when a finite
* orthometric source height is available, else `geoidHeight` alone (treats
* the point as if it were at the geoid, i.e. H≈0 — a coast-level prior).
* Assumes `ensureGeoidReady()` has already resolved.
* @param {number} lat
* @param {number} lon
* @param {number} [sourceOrthometricM]
* @returns {number}
*/
function geoidFallback(lat, lon, sourceOrthometricM) {
const n = geoidHeight(lat, lon);
return Number.isFinite(sourceOrthometricM) ? sourceOrthometricM + n : n;
}
/**
* Resolves ellipsoidal ground height for a batch of coordinates, in order.
*
* - Results already warm in the in-memory cache are returned without any
* network call.
* - Remaining (deduplicated) coordinates are sent to `/api/terrain/heights`
* in sequential chunks of <=64 points.
* - If a chunk request fails (network error, non-ok HTTP, malformed body),
* every point in THAT chunk falls back to geoid math
* (`sourceOrthometricM + geoidHeight` or `geoidHeight` alone) rather than
* failing the whole batch — a transient proxy outage in the middle of a
* large batch shouldn't blank out entities whose chunk happened to land
* earlier or later.
* - Every resolved value (proxy or fallback) is written back into the
* in-memory cache before this function returns, so a later call — even
* with a real proxy round-trip pending — sees a warm hit.
*
* @param {Array<{lat:number, lon:number, sourceOrthometricM?:number}>} coords
* @returns {Promise<Array<{ellipsoid:number, source:'reearth'|'geoid-fallback'}>>}
* Same length and order as `coords`.
*/
async function resolveEllipsoidalGround(coords) {
signal?.throwIfAborted();
if (!Array.isArray(coords) || coords.length === 0) return [];
// Build the per-input work list (key + original index) up front so the
// final assembly can map back positionally regardless of how many inputs
// shared a cache key or a chunk.
const work = coords.map((c, index) => ({
index,
lat: c.lat,
lon: c.lon,
sourceOrthometricM: c.sourceOrthometricM,
key: cacheKey(c.lat, c.lon),
}));
// Points not yet warm in the cache, deduplicated by key (multiple inputs —
// even within the same call — naming the same rounded coordinate should
// only ever hit the network once). Round 5: a geoid-FALLBACK entry does
// NOT count as permanently warm — it was cached when the proxy failed
// mid-burst and (at elevated terrain) is a sea-level poison that sank every
// floor built on it. Give failed keys a short cooldown before re-requesting
// so warm consumers do not hammer a failing proxy; after it expires, a
// successful response replaces the fallback entry and clears the path.
const uncached = [];
const seenKeys = new Set();
const now = Date.now();
for (const item of work) {
const entry = cache.get(item.key);
const fallbackCooling =
entry?.source === 'geoid-fallback' &&
Number.isFinite(entry.retryAt) &&
now < entry.retryAt;
if (
(entry && entry.source === 'reearth') ||
fallbackCooling ||
seenKeys.has(item.key)
)
continue;
seenKeys.add(item.key);
uncached.push(item);
}
// Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's
// failure is isolated to that chunk's points (geoid fallback), so a single
// bad chunk doesn't lose results for the rest of a large batch.
for (let i = 0; i < uncached.length; i += CHUNK_SIZE) {
const chunk = uncached.slice(i, i + CHUNK_SIZE);
try {
const resolved = await fetchChunk(chunk);
signal?.throwIfAborted();
for (const item of chunk) {
const ellipsoid = resolved.get(item.key);
// Round 6: only a FINITE value may be cached as 'reearth'. A point
// the upstream response omitted used to cache {ellipsoid: undefined,
// source: 'reearth'} — permanently "warm" yet empty, so its floor
// read null forever and every later warm skipped it (ATL verify:
// one contact frozen at the geoid while its neighbors resolved).
// An omitted point now caches nothing and retries on the next warm.
if (Number.isFinite(ellipsoid)) {
cache.set(item.key, { ellipsoid, source: 'reearth' });
}
}
} catch {
signal?.throwIfAborted();
// Proxy down (or cold cache had nothing to serve-stale) — fall back to
// geoid math for every point in this chunk. `ensureGeoidReady()` is
// awaited lazily, only on the fallback path, so the common (proxy
// healthy) case never pays for the geoid grid's dynamic import.
await ensureGeoidReady();
signal?.throwIfAborted();
for (const item of chunk) {
const ellipsoid = geoidFallback(
item.lat,
item.lon,
item.sourceOrthometricM,
);
cache.set(item.key, {
ellipsoid,
source: 'geoid-fallback',
retryAt: Date.now() + GEOID_FALLBACK_COOLDOWN_MS,
});
}
}
}
// Assemble the output in the original input order from the cache. A point
// the upstream omitted has NO entry (round 6 — deliberately uncached so it
// retries later): report it unresolved instead of throwing.
return work.map((item) => {
const entry = cache.get(item.key);
return entry
? { ellipsoid: entry.ellipsoid, source: entry.source }
: { ellipsoid: null, source: 'unresolved' };
});
}
signal?.addEventListener('abort', () => cache.clear(), { once: true });
return {
cachedEllipsoidalGround,
cachedRealEllipsoidalGround,
resolveEllipsoidalGround,
};
}