diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 05240d922..5e7c36be2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1347,6 +1347,27 @@ and snapshot source. The standalone adapter keeps the existing USGS daily feed, M2.5+ filtering, static discs, magnitude labels and analyst records. Disabling or destroying the layer cancels pending work and ignores late results. +## Rendered-mesh floor readings — November 9, 2026 + +On the photoreal stack the shared floor (`cachedGroundFloor`) answers from +rendered-mesh readings per coarse cell (0.001°, about 110 × 80 m), sampled by +`src/services/meshFloorSampler.js` and gated against the DEM by +`reportValidatedMeshFloorCell` in `src/services/groundFloor.js`. The probe goes +where the caller's point is (a vehicle or contact), not to the cell's centre; +the sampler reads a cell at up to eight spots about 12–17 m or more apart, and +each reading that passes the gate is kept at its point (ground snaps add theirs +the same way; a cell keeps at most sixteen). A read answers from the cell's +reading nearest the asked point, so a viaduct and the street beside it in one +cell keep their own heights; two readings within about 5 m of each other keep +the lower. A spot refused only because the cell's DEM prior was cold is tried +again later. Before this, one probe at the cell's centre latched for the +session, so a centre on a roof lifted everything in the cell (a Haymarket bus +drew 23.5 m above the street, on the garage roof) and a centre in the river put +Red Line trains on the Longfellow Bridge 6–9 m under the deck. Residual: the +probe is vertical, so a vehicle directly under a deck or a roof overhang reads +the structure above it, and a vehicle between readings takes the nearest one, so +its floor steps where the nearest reading changes. + ## Vessel components and sources `src/data/aisLiveVessels.js` assembles `createVesselLayer` from the @@ -3148,7 +3169,7 @@ This is the current runtime/source-of-truth snapshot for the project. > `HELD_SNAP_CONTRADICTION_M` (5 m) ABOVE the held value drops the hold and the > contact is COLD again, back to the floored 2D billboard. The rule is > ONE-SIDED on purpose — it targets BURIAL. A cell reading BELOW a real sample -> is the floor chain's expected under-read (one-shot latch over ~111 m, +> is the floor chain's expected under-read (a few point readings over ~111 m, > neighbours lean lowest, `displayFloorHeightM` only ever raises), not evidence > of anything, and a two-sided cut was disproved on the track rig by a planted > cell 66.7 m below a real sample at the same spot. Accepted residual, stated diff --git a/src/data/groundFloor.js b/src/data/groundFloor.js index fed7a6cd6..978f30218 100644 --- a/src/data/groundFloor.js +++ b/src/data/groundFloor.js @@ -22,6 +22,7 @@ export const { reportMeshFloorCell, meshFloorSampleWithinPrior, reportValidatedMeshFloorCell, + meshFloorPriorReady, cachedMeshFloor, _clearMeshFloorCellsForTest, cachedGroundFloor, diff --git a/src/data/meshFloorSampler.test.mjs b/src/data/meshFloorSampler.test.mjs index f651d72f8..d33c4873d 100644 --- a/src/data/meshFloorSampler.test.mjs +++ b/src/data/meshFloorSampler.test.mjs @@ -24,7 +24,7 @@ import { sampleMeshFloorCells } from './meshFloorSampler.js'; import { resolveEllipsoidalGround } from './terrainHeights.js'; import { cachedGroundFloor, cachedMeshFloor, setMeshFloorPreferred, - _clearMeshFloorCellsForTest, + _clearMeshFloorCellsForTest, reportValidatedMeshFloorCell, } from './groundFloor.js'; /** Distinct site per test — terrainHeights' cache is module-global and warm @@ -137,3 +137,180 @@ test('sampler: a cell just across the antimeridian is still near the viewer', () sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 }); assert.equal(calls, 2, 'about 200 m apart, so both cells are probed'); }); + +test('sampler: the probe goes where the point is, not to the cell centre', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + const asked = []; + const scene = fakeScene((carto) => { + asked.push(Cesium.Math.toDegrees(carto.longitude)); + return 122.0; + }); + const point = { lat: cell.lat + 0.0003, lon: cell.lon + 0.0004 }; + sampleMeshFloorCells(scene, [point], { viewerLat: cell.lat, viewerLon: cell.lon }); + assert.equal(asked.length, 1); + assert.ok(Math.abs(asked[0] - point.lon) < 1e-9, `probed at ${asked[0]}, not ${point.lon}`); +}); + +test('sampler: a viaduct and the street beside it in one cell keep their own heights', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + // A viaduct deck 9 m up along one line of the cell, the street elsewhere. + const deckLat = cell.lat + 0.0003; + let calls = 0; + const scene = fakeScene((carto) => { + calls += 1; + return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0; + }); + const opts = { viewerLat: cell.lat, viewerLon: cell.lon }; + const onDeck = { lat: deckLat, lon: cell.lon }; + const street = { lat: cell.lat - 0.0003, lon: cell.lon }; + sampleMeshFloorCells(scene, [onDeck], opts); + sampleMeshFloorCells(scene, [street], opts); + assert.equal(calls, 2); + assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon + 0.0001), 129.0, 'the train stays on the deck'); + assert.equal(cachedMeshFloor(street.lat, street.lon - 0.0001), 121.0, 'the bus stays on the street'); + assert.equal(cachedGroundFloor(street.lat, street.lon), 121.0); + sampleMeshFloorCells(scene, [{ lat: deckLat, lon: cell.lon + 0.00001 }], opts); + assert.equal(calls, 2, 'a point next to one already probed is not probed again'); +}); + +test('sampler: a roof over the cell centre does not lift a bus on the street', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + // A roof 22 m up covering the cell's centre; the street 30 m west of it. + const scene = fakeScene((carto) => + Math.abs(Cesium.Math.toDegrees(carto.longitude) - cell.lon) < 0.0001 ? 142.0 : 121.0); + const bus = { lat: cell.lat, lon: cell.lon - 0.0003 }; + sampleMeshFloorCells(scene, [bus], { viewerLat: cell.lat, viewerLon: cell.lon }); + assert.equal(cachedGroundFloor(bus.lat, bus.lon), 121.0); +}); + +test('sampler: a second reading at the same spot keeps the lower one', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + assert.equal(reportValidatedMeshFloorCell(cell.lat, cell.lon, 131.0), true); + assert.equal(reportValidatedMeshFloorCell(cell.lat + 0.00002, cell.lon, 122.0), true); + assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.0); + assert.equal(reportValidatedMeshFloorCell(cell.lat, cell.lon + 0.00002, 140.0), true); + assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.0, 'a higher one never raises it'); +}); + +test('sampler: a cell with a reading is probed a few times at most', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + let calls = 0; + const scene = fakeScene(() => { + calls += 1; + return 121.0; + }); + const opts = { viewerLat: cell.lat, viewerLon: cell.lon }; + for (let i = 0; i < 20; i++) + sampleMeshFloorCells( + scene, + [{ lat: cell.lat - 0.0004 + (i % 5) * 0.0002, lon: cell.lon - 0.00045 + Math.floor(i / 5) * 0.0003 }], + opts, + ); + assert.equal(calls, 8); +}); + +test('sampler: a cell with no accepted reading is probed again on every call', async () => { + reset(); + const cell = nextSite(); + let calls = 0; + const scene = fakeScene(() => { + calls += 1; + return 138.0; + }); + const opts = { viewerLat: cell.lat, viewerLon: cell.lon }; + for (let i = 0; i < 3; i++) sampleMeshFloorCells(scene, [cell], opts); + assert.equal(calls, 3, 'no DEM prior yet, so nothing is kept and the cell is retried'); + await seedDem(cell, 120.0); + sampleMeshFloorCells(scene, [cell], opts); + assert.equal(cachedMeshFloor(cell.lat, cell.lon), 138.0, 'and lands once the prior is warm'); +}); + +test('sampler: a spot whose reading is refused does not use up the cell', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + // A tower 260 m up where the first vehicle waits; the street elsewhere and + // a deck 9 m up along one line of the cell. + const towerLon = cell.lon + 0.0004; + const deckLat = cell.lat + 0.0003; + const scene = fakeScene((carto) => { + if (Math.abs(Cesium.Math.toDegrees(carto.longitude) - towerLon) < 1e-7) return 380.0; + return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0; + }); + const opts = { viewerLat: cell.lat, viewerLon: cell.lon }; + for (let i = 0; i < 10; i++) sampleMeshFloorCells(scene, [{ lat: cell.lat, lon: towerLon }], opts); + assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'the tower is refused every time'); + sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts); + sampleMeshFloorCells(scene, [{ lat: deckLat, lon: cell.lon - 0.0003 }], opts); + assert.equal(cachedMeshFloor(deckLat, cell.lon - 0.0003), 129.0, 'the deck is still probed'); +}); + +test('ground floor: a cell keeps at most sixteen readings', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + for (let i = 0; i < 40; i++) { + const lat = cell.lat - 0.00045 + (i % 8) * 0.0001; + const lon = cell.lon - 0.00045 + Math.floor(i / 8) * 0.0002; + reportValidatedMeshFloorCell(lat, lon, 121.0 + i); + } + // Readings 0..15 are kept; reading 39 (at the far corner) is not, so that + // spot answers from the nearest kept one. + assert.equal(cachedMeshFloor(cell.lat - 0.00045, cell.lon - 0.00045), 121.0); + assert.equal(cachedMeshFloor(cell.lat + 0.00025, cell.lon + 0.00035), 121.0 + 15); +}); + +test('sampler: a spot refused while the DEM was cold is probed again once it is warm', async () => { + reset(); + const cell = nextSite(); + const deckLat = cell.lat + 0.0003; + let calls = 0; + const scene = fakeScene((carto) => { + calls += 1; + return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0; + }); + const opts = { viewerLat: cell.lat, viewerLon: cell.lon }; + const onDeck = { lat: deckLat, lon: cell.lon }; + sampleMeshFloorCells(scene, [onDeck], opts); + assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon), null, 'no prior, nothing kept'); + await seedDem(cell, 120.0); + sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts); + sampleMeshFloorCells(scene, [onDeck], opts); + assert.equal(calls, 3); + assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon), 129.0); +}); + +test('sampler: a spot refused while the DEM prior was evicted is probed again', async () => { + const { createGroundFloor } = await import('../services/groundFloor.js'); + const { createMeshFloorSampler } = await import('../services/meshFloorSampler.js'); + let prior = 120.0; + const terrain = { + cachedRealEllipsoidalGround: () => prior, + resolveEllipsoidalGround: async () => {}, + }; + const groundFloor = createGroundFloor({ terrain }); + const sampler = createMeshFloorSampler({ groundFloor }); + const cell = { lat: 41.5, lon: -97.66 }; + const deckLat = cell.lat + 0.0003; + const scene = fakeScene((carto) => + Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0); + const opts = { viewerLat: cell.lat, viewerLon: cell.lon }; + const onDeck = { lat: deckLat, lon: cell.lon }; + sampler.sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts); + prior = null; // the cell's DEM entry is evicted; the street reading stays + sampler.sampleMeshFloorCells(scene, [onDeck], opts); + assert.equal(groundFloor.cachedMeshFloor(onDeck.lat, onDeck.lon), 121.0); + prior = 120.0; + sampler.sampleMeshFloorCells(scene, [onDeck], opts); + assert.equal(groundFloor.cachedMeshFloor(onDeck.lat, onDeck.lon), 129.0); +}); diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js index 362ad4789..a1402c6c2 100644 --- a/src/layers/cctv/geometry.js +++ b/src/layers/cctv/geometry.js @@ -448,7 +448,7 @@ export function createGeometry({ state: layerState, services, parts, source }) { } // Photoreal regime. Sampling is delegated to the shared coarse-cell - // sampler. It remains event-driven, one-shot per cell, and keeps its + // sampler. It remains event-driven, a few probes per cell, and keeps its // existing acceptance window; CCTV adds no rooftop rejection policy. if (sampleGround && parts.model.projectionTilesReady()) { record.groundMeshSampleRequestCount = diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index ec6c2933b..d48d12774 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -300,8 +300,8 @@ export function createFlightSnapshotRenderer({ // floor cells collected in the loop (fire-and-forget, single-flight; // read synchronously by NEXT poll's clamp — the military-layer pattern). warmGroundFloor(floorWarmPoints); - // Round 4: sample the RENDERED mesh for those same cells (one-shot per - // cell, budget-capped, viewer-proximate, google-3d regime only). Own + // Round 4: sample the RENDERED mesh for those same cells (a few probes + // per cell, budget-capped, viewer-proximate, google-3d regime only). Own // billboards/models are excluded so a vertical probe can't land on an // aircraft instead of the pavement. sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, { diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 7ed2b1c1f..eb3a341f8 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -228,8 +228,8 @@ export function createMilitarySnapshotRenderer({ const bbStale = flightState._billboards.get(icao24); if (bbStale) bbStale.position = liftedPos; } - // Round 4: sample the RENDERED mesh for those cells (one-shot per cell, - // budget-capped, viewer-proximate, google-3d regime only), excluding + // Round 4: sample the RENDERED mesh for those cells (a few probes per + // cell, budget-capped, viewer-proximate, google-3d regime only), excluding // this layer's own billboards/models from the probe. { const viewerCarto = diff --git a/src/services/groundFloor.js b/src/services/groundFloor.js index 27b7dc5e4..3b4939fc5 100644 --- a/src/services/groundFloor.js +++ b/src/services/groundFloor.js @@ -418,13 +418,26 @@ export function createGroundFloor({ terrain, signal }) { // 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 + // at the callers' points, a few per coarse cell near the viewer, each kept + // where it was taken and read back from the nearest (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} coarse cell key -> rendered-surface height (m, ellipsoidal). */ + /** @type {Map>} + * coarse cell key -> rendered-surface readings (m, ellipsoidal), each kept + * at the point it was probed. A read answers from the reading nearest the + * asked point, so a viaduct and the street beside it in one cell keep their + * own heights. */ const _meshCells = new Map(); + /** @constant {number} Readings closer than this (degrees, about 5 m) are + * one spot: the lower one is kept there. */ + const MESH_READING_SAME_SPOT_DEG = 0.00005; + /** @constant {number} Readings kept per cell. The sampler probes a cell at + * most eight times, but ground snaps report at each aircraft's position; + * past this many, a reading away from the others is not kept, so a busy + * apron cell stays a short list to scan. */ + const MESH_READINGS_PER_CELL = 16; /** @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 @@ -444,7 +457,8 @@ export function createGroundFloor({ terrain, signal }) { /** * Records a sampled rendered-surface height for the cell containing the - * coordinate (one-shot latch — first accepted sample wins). + * coordinate, unchecked, when the cell has no reading yet. The sampler goes + * through `reportValidatedMeshFloorCell`, which keeps a reading per point. * @param {number} lat @param {number} lon @param {number} heightM */ function reportMeshFloorCell(lat, lon, heightM) { @@ -456,7 +470,7 @@ export function createGroundFloor({ terrain, signal }) { return; const c = coarseFloorCoord(lat, lon); const key = `${c.lat},${c.lon}`; - if (!_meshCells.has(key)) _meshCells.set(key, heightM); + if (!_meshCells.has(key)) _meshCells.set(key, [{ lat, lon, h: heightM }]); } /** Return whether a rendered-mesh sample is plausible against a real DEM prior. */ @@ -471,9 +485,11 @@ export function createGroundFloor({ terrain, signal }) { /** * 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. + * Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD + * or aircraft hit would otherwise poison every floor consumer in the cell. + * The reading is kept at the point it was taken; a second reading at the + * same spot (within about 5 m) keeps the lower of the two. A cell keeps at + * most MESH_READINGS_PER_CELL readings. * @returns {boolean} Whether the sample passed and was reported. */ function reportValidatedMeshFloorCell(lat, lon, heightM) { @@ -481,13 +497,41 @@ export function createGroundFloor({ terrain, signal }) { const cell = coarseFloorCoord(lat, lon); const prior = cachedRealEllipsoidalGround(cell.lat, cell.lon); if (!meshFloorSampleWithinPrior(heightM, prior)) return false; - reportMeshFloorCell(cell.lat, cell.lon, heightM); + const key = `${cell.lat},${cell.lon}`; + const readings = _meshCells.get(key); + if (!readings) { + _meshCells.set(key, [{ lat, lon, h: heightM }]); + return true; + } + const same = readings.find( + (r) => + Math.abs(r.lat - lat) < MESH_READING_SAME_SPOT_DEG && + Math.abs(r.lon - lon) < MESH_READING_SAME_SPOT_DEG, + ); + if (same) { + if (heightM < same.h) same.h = heightM; + } else if (readings.length < MESH_READINGS_PER_CELL) { + readings.push({ lat, lon, h: 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). + * Whether the cell holding the point has the real DEM prior that + * `reportValidatedMeshFloorCell` judges a reading against. + * @param {number} lat @param {number} lon + * @returns {boolean} + */ + function meshFloorPriorReady(lat, lon) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; + const c = coarseFloorCoord(lat, lon); + return cachedRealEllipsoidalGround(c.lat, c.lon) != null; + } + + /** + * Synchronous mesh-cell read — the cell's reading nearest the point, or + * 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} */ @@ -495,8 +539,20 @@ export function createGroundFloor({ terrain, signal }) { 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; + const readings = _meshCells.get(`${c.lat},${c.lon}`); + if (!readings) return null; + if (readings.length === 1) return readings[0].h; + const k = Math.cos((lat * Math.PI) / 180) ** 2; + let best = readings[0], + bestD = Infinity; + for (const r of readings) { + const d = (r.lat - lat) ** 2 + k * (r.lon - lon) ** 2; + if (d < bestD) { + bestD = d; + best = r; + } + } + return best.h; } /** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */ @@ -662,6 +718,7 @@ export function createGroundFloor({ terrain, signal }) { reportMeshFloorCell, meshFloorSampleWithinPrior, reportValidatedMeshFloorCell, + meshFloorPriorReady, cachedMeshFloor, _clearMeshFloorCellsForTest, cachedGroundFloor, diff --git a/src/services/groundSnap.js b/src/services/groundSnap.js index eecb1fa34..b1d75cfbb 100644 --- a/src/services/groundSnap.js +++ b/src/services/groundSnap.js @@ -101,7 +101,7 @@ const HELD_SNAP_MAX_DRIFT_M = 250; * 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 + * contradiction. Cells hold a few point readings 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 diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js index c967f80c8..083789ea9 100644 --- a/src/services/meshFloorSampler.js +++ b/src/services/meshFloorSampler.js @@ -11,6 +11,7 @@ export function createMeshFloorSampler({ coarseFloorCoord, cachedMeshFloor, reportValidatedMeshFloorCell, + meshFloorPriorReady, setMeshFloorPreferred, meshFloorPreferred, cachedGroundFloor, @@ -23,13 +24,16 @@ export function createMeshFloorSampler({ // 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. + // This module samples the mesh height at the callers' points, a few probes + // per coarse cell, and reports them to groundFloor.js, which keeps each + // reading that passes its gate at its own point; cachedGroundFloor() then + // answers mesh-first, from the reading nearest the asked point. // // 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, + // visible at all, (b) capped per call, (c) at most MAX_PROBES_PER_CELL + // readings per cell for the session, each at a point apart from the others, // (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. // @@ -49,6 +53,16 @@ export function createMeshFloorSampler({ * streamed LOD under a high camera is coarse everywhere, so every probe * would latch junk. */ const MAX_CAMERA_HEIGHT_M = 25_000; + /** @constant {number} Readings per cell: a cell is probed at most this + * many times, each at a point apart from the others, and groundFloor keeps + * each reading at its point (a read answers from the nearest one). */ + const MAX_PROBES_PER_CELL = 8; + /** @constant {number} How far apart (degrees, about 17 m) a new probe must + * be from the cell's earlier ones to be worth taking. */ + const PROBE_APART_DEG = 0.00015; + /** @type {Map>} Points probed + * per cell that returned a reading. */ + const _probedPoints = new Map(); /** @type {Cesium.Cartographic} Scratch for probe coordinates. */ const _scratchProbe = new Cesium.Cartographic(); @@ -82,7 +96,7 @@ export function createMeshFloorSampler({ /** * Samples the rendered mesh height for the coarse cells containing `points`, - * one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per + * a few probes 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. * @@ -119,8 +133,21 @@ export function createMeshFloorSampler({ const cell = coarseFloorCoord(p.lat, p.lon); const key = `${cell.lat},${cell.lon}`; if (attempted.has(key)) continue; + // A cell with a reading is probed again only at a point well apart + // from the ones already probed, a few times at most, so the cell's + // readings follow where its vehicles actually are. + const probes = _probedPoints.get(key); + if ( + cachedMeshFloor(cell.lat, cell.lon) != null && + (probes?.length >= MAX_PROBES_PER_CELL || + probes?.some( + (q) => + Math.abs(q.lat - p.lat) < PROBE_APART_DEG && + Math.abs(q.lon - p.lon) < PROBE_APART_DEG, + )) + ) + continue; attempted.add(key); - if (cachedMeshFloor(cell.lat, cell.lon) != null) continue; // one-shot latch if ( Number.isFinite(viewerLat) && Number.isFinite(viewerLon) && @@ -131,9 +158,11 @@ export function createMeshFloorSampler({ } let height; try { + // Probe where the caller's point is, not at the cell's centre: a + // centre that falls on a roof would lift everything in the cell. const carto = Cesium.Cartographic.fromDegrees( - cell.lon, - cell.lat, + p.lon, + p.lat, 0, _scratchProbe, ); @@ -171,7 +200,23 @@ export function createMeshFloorSampler({ // 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); + const accepted = reportValidatedMeshFloorCell(p.lat, p.lon, height); + // A spot counts as probed once the gate has judged it against a real + // prior: accepted, or refused with the cell's DEM warm. A spot refused + // only because the DEM was still cold is tried again later. Each spot is + // recorded once, so a cell retried while it has no reading does not fill + // its probe allowance with the same spot. + if (!accepted && !meshFloorPriorReady(cell.lat, cell.lon)) continue; + if (!probes) _probedPoints.set(key, [{ lat: p.lat, lon: p.lon }]); + else if ( + probes.length < MAX_PROBES_PER_CELL && + !probes.some( + (q) => + Math.abs(q.lat - p.lat) < PROBE_APART_DEG && + Math.abs(q.lon - p.lon) < PROBE_APART_DEG, + ) + ) + probes.push({ lat: p.lat, lon: p.lon }); } }