mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor: own application request operations and surface caches
This commit is contained in:
1 parent
310809e8f1
commit
ac977bd8bc
53 files changed
+4388
-3317
No files matched your search
+4
-580
@@ -1,580 +1,4 @@
|
||||
// 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.
|
||||
import { cachedRealEllipsoidalGround, resolveEllipsoidalGround } from './terrainHeights.js';
|
||||
|
||||
/** @constant {number} Metres added above the resolved floor so a clamped
|
||||
* billboard/waypoint sits ON the surface instead of z-fighting it. */
|
||||
export const GROUND_FLOOR_LIFT_M = 1.5;
|
||||
/** Accepted rendered-mesh window around a real ellipsoidal DEM prior. */
|
||||
export const MESH_FLOOR_BELOW_PRIOR_M = 15;
|
||||
export 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.
|
||||
*/
|
||||
export 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}
|
||||
*/
|
||||
export 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".
|
||||
*/
|
||||
export 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. */
|
||||
export 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. */
|
||||
export 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.
|
||||
*/
|
||||
export 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. */
|
||||
export 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. */
|
||||
export 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. */
|
||||
export 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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. */
|
||||
export function setMeshFloorPreferred(preferred) {
|
||||
_meshPreferred = !!preferred;
|
||||
}
|
||||
|
||||
/** @returns {boolean} Whether mesh-floor cells currently apply. */
|
||||
export 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
|
||||
*/
|
||||
export 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. */
|
||||
export 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.
|
||||
*/
|
||||
export 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}
|
||||
*/
|
||||
export 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). */
|
||||
export 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.
|
||||
*/
|
||||
export 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. */
|
||||
export 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>}
|
||||
*/
|
||||
export function resolveGroundFloorCellsBounded(points) {
|
||||
return Promise.race([
|
||||
resolveGroundFloorCells(points),
|
||||
new Promise((resolve) => setTimeout(resolve, FLOOR_RESOLVE_DEADLINE_MS)),
|
||||
]);
|
||||
}
|
||||
|
||||
export 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 (_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
|
||||
*/
|
||||
export function warmGroundFloor(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) || _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);
|
||||
}
|
||||
import { createGroundFloor } from '../services/groundFloor.js';
|
||||
import * as terrain from './terrainHeights.js';
|
||||
const groundFloor = createGroundFloor({ terrain });
|
||||
export const { 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 } = groundFloor;
|
||||
Reference in new issue
Block a user