diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5e7c36be2..0739cf5e1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1360,13 +1360,16 @@ 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. +again later. Transit offers the sampler the latest point of each vehicle's path +in a cell (its newest fix in its own cell), and the grounded-flight display +clamp reads at the contact, held inside its sticky cell. 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 diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 7fe197c30..1d6007e8f 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -20,9 +20,11 @@ import { setMilitaryLayerActive } from './militaryRegistry.js'; import { GROUND_FLOOR_LIFT_M, reportMeshFloorCell, + reportValidatedMeshFloorCell, setMeshFloorPreferred, _clearMeshFloorCellsForTest, } from './groundFloor.js'; +import { resolveEllipsoidalGround } from './terrainHeights.js'; import { _setTrackedOverlayHostForTest, destroyTrackedReadout, @@ -1181,6 +1183,39 @@ test('display floor: a grounded contact that drifted onto a higher cell is lifte assert.ok(Math.abs(Cesium.Math.toDegrees(c.longitude) + 97.6604) < 1e-6); }); +test('display floor: reads the reading nearest the contact, not the cell centre', async () => { + _clearDisplayFloorStateForTest(); + _clearMeshFloorCellsForTest(); + setMeshFloorPreferred(true); + const realFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ results: [{ ellipsoid: 120 }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + try { + await resolveEllipsoidalGround([{ lat: 30.317, lon: -97.717 }]); + } finally { + globalThis.fetch = realFetch; + } + // A hangar roof over the cell's centre; the apron where the contact is. + assert.equal(reportValidatedMeshFloorCell(30.317, -97.717, 142), true); + assert.equal(reportValidatedMeshFloorCell(30.3173, -97.7173, 121), true); + const pos = Cesium.Cartesian3.fromDegrees(-97.7173, 30.3173, 100); + const out = _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + ); + const c = _floorCarto(out); + assert.ok( + Math.abs(c.height - (121 + GROUND_FLOOR_LIFT_M)) < 0.05, + `height ${c.height} should be the apron's 121 + lift`, + ); + _clearMeshFloorCellsForTest(); + _clearDisplayFloorStateForTest(); +}); + test('display floor: an AIRBORNE contact over the same cell is never lifted', () => { _clearDisplayFloorStateForTest(); _clearMeshFloorCellsForTest(); diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index a14c8c4ac..c09522153 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -37,6 +37,22 @@ import { DISPLAY_CORRIDOR_FAIR_SHARE, } from './policy.js'; +/** Half a coarse floor cell (0.001°), less a margin so rounding keeps a + * clamped coordinate inside the cell. */ +const HALF_CELL_DEG = 0.00049; + +/** + * A coordinate held within half a cell of the cell's centre (the short way + * round for longitude), so a read lands in the given cell. + * @param {number} deg @param {number} centreDeg + * @returns {number} + */ +function clampToCell(deg, centreDeg) { + let d = deg - centreDeg; + d -= 360 * Math.round(d / 360); + return centreDeg + Math.max(-HALF_CELL_DEG, Math.min(HALF_CELL_DEG, d)); +} + export function createMotion({ flightState, services, @@ -865,12 +881,15 @@ export function createMotion({ ); // Boundary hysteresis: a position jittering across a cell edge would flip // floors at fleet-tick rate (see stickyFloorCell). - const cell = stickyFloorCell( - Cesium.Math.toDegrees(carto.latitude), - Cesium.Math.toDegrees(carto.longitude), - state?.cell, + const latDeg = Cesium.Math.toDegrees(carto.latitude); + const lonDeg = Cesium.Math.toDegrees(carto.longitude); + const cell = stickyFloorCell(latDeg, lonDeg, state?.cell); + // A cell holds rendered-surface readings at the points they were taken, + // so the floor is read where the contact is, held inside its sticky cell. + const floor = cachedGroundFloor( + clampToCell(latDeg, cell.lat), + clampToCell(lonDeg, cell.lon), ); - const floor = cachedGroundFloor(cell.lat, cell.lon); const next = state || { cell, in: new Cesium.Cartesian3(), diff --git a/src/layers/transit/height.js b/src/layers/transit/height.js index 2e2945a88..027dc7d41 100644 --- a/src/layers/transit/height.js +++ b/src/layers/transit/height.js @@ -124,7 +124,14 @@ export function createHeight({ state, services, parts }) { const cells = new Map(); const collect = (point, answer) => { if (!onGround) return; - if (!cells.has(answer.cell)) + // A cell keeps the last point offered in it: the path is walked + // oldest first, so that is where the vehicle is now or about to be, + // and the mesh sampler reads the surface there. + const held = cells.get(answer.cell); + if (held) { + held.lat = point.lat; + held.lon = point.lon; + } else cells.set(answer.cell, { lat: point.lat, lon: point.lon, @@ -136,6 +143,13 @@ export function createHeight({ state, services, parts }) { const newest = entry.fixes.at(-1); const answer = requestHeight(newest.lat, newest.lon, entry); entry.heightCell = answer.cell; + // The selected vehicle's path also offers its history, newest segment + // first; the vehicle's own cell is read where its newest fix is. + const own = cells.get(answer.cell); + if (own) { + own.lat = newest.lat; + own.lon = newest.lon; + } if (answer.height !== null) { entry.heightM = answer.height; entry.heightResolved = true; diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index e36d87de8..fee9a60ed 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -2938,6 +2938,114 @@ test('mesh refinement reaches all 100 retained DEM cells within five poll budget assert.equal(probed.size, 100); }); +test('mesh refinement probes the latest point the path offers in a cell', async (t) => { + const { createHeight } = await import('./height.js'); + // Three points along one cell's stretch of path, oldest first. + const path = [ + { lat: 42.3611, lon: -71.0751 }, + { lat: 42.3612, lon: -71.0753 }, + { lat: 42.3614, lon: -71.0754 }, + ]; + const entry = { key: 'train', to: path[0], fixes: [path.at(-1)] }; + const probed = []; + const state = { + _enabled: true, + _selectedKey: null, + _visible: new Set([entry]), + _vehicles: new Map([[entry.key, entry]]), + _floorCursor: 0, + _heightDirty: new Set(), + _viewer: { camera: { positionCartographic: { height: 100 } } }, + }; + const height = createHeight({ + state, + services: { + render: { governorRequestRender() {} }, + ground: { + coarseFloorCoord: (lat, lon) => ({ + lat: Number(lat.toFixed(3)), + lon: Number(lon.toFixed(3)), + }), + cachedGroundFloor: () => 10, + GROUND_FLOOR_LIFT_M: 1.5, + }, + mesh: { + sampleMeshFloorCells(scene, points) { + probed.push(...points.map((p) => [p.lat, p.lon])); + }, + }, + }, + parts: { + trails: { + prepareEntry(e, collect) { + if (collect) + for (const point of path) + collect(point, height.requestHeight(point.lat, point.lon)); + }, + }, + rendering: { requestVisibility() {} }, + }, + }); + t.after(() => height.clear()); + height.anchorFloors(); + assert.deepEqual(probed, [[42.3614, -71.0754]]); +}); + +test('the selected vehicle is probed at its newest fix in its own cell', async (t) => { + const { createHeight } = await import('./height.js'); + // The selected path offers its active corridor first, then its history + // newest segment first, so the last offer in the cell is an older point. + const newest = { lat: 42.3614, lon: -71.0754 }; + const offers = [ + newest, + { lat: 42.3612, lon: -71.0753 }, + { lat: 42.3611, lon: -71.0751 }, + ]; + const entry = { key: 'train', to: offers[0], fixes: [newest] }; + const probed = []; + const state = { + _enabled: true, + _selectedKey: entry.key, + _visible: new Set([entry]), + _vehicles: new Map([[entry.key, entry]]), + _floorCursor: 0, + _heightDirty: new Set(), + _viewer: { camera: { positionCartographic: { height: 100 } } }, + }; + const height = createHeight({ + state, + services: { + render: { governorRequestRender() {} }, + ground: { + coarseFloorCoord: (lat, lon) => ({ + lat: Number(lat.toFixed(3)), + lon: Number(lon.toFixed(3)), + }), + cachedGroundFloor: () => 10, + GROUND_FLOOR_LIFT_M: 1.5, + }, + mesh: { + sampleMeshFloorCells(scene, points) { + probed.push(...points.map((p) => [p.lat, p.lon])); + }, + }, + }, + parts: { + trails: { + prepareEntry(e, collect) { + if (collect) + for (const point of offers) + collect(point, height.requestHeight(point.lat, point.lon)); + }, + }, + rendering: { requestVisibility() {} }, + }, + }); + t.after(() => height.clear()); + height.anchorFloors(); + assert.deepEqual(probed, [[42.3614, -71.0754]]); +}); + test('selection churn releases past geometry on every retained vehicle', async (t) => { const app = harness(t, { floorAt: () => 10 }); const { mergeHistory, seek } = await import('../../data/contactPlayback.js');