Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 f04308e45d docs(changelog): add the #1706 fragment
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 16:05:55 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 9582f2f28b perf(alpr): read anchors from the terrain globe and sample outside the paint
The overlay took up to six scene.sampleHeight depth renders inside one
paint, and kept the answers on entities, so every refresh and every
re-enable sampled the same cameras again. On a terrain globe the anchor now
comes from globe.getHeight; answers are kept per camera id until the map
source changes; and what sampling remains runs after the paint, one per
frame.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 16:03:18 +01:00
12 changed files with 306 additions and 710 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
- ALPR camera badges no longer take depth samples while the overlay paints, or
again each time the layer is switched back on. On a terrain globe their
height comes from the loaded terrain and is remembered per camera; some
badges move a few metres onto the drawn surface (mazy1998, #1706).
@@ -1,4 +0,0 @@
- Make a whole-globe view of Live Flights about three times faster on a slow
CPU. Aircraft at cruise altitude no longer ask for the ground under them on
every pass, and a ground floor read no longer formats numbers into text
(mazy1998, #1708).
+9 -11
View File
@@ -1513,10 +1513,14 @@ tile streamed in; floors resolve before new badges appear (bounded wait), and a
badge whose cell has not resolved clamps until it does, then moves in place.
Overlay anchors start on the same floor and write marker properties only on
change. Once the camera is still and tiles have settled, each overlay anchor is
replaced once by the rendered surface under it (two depth samples per paint,
accepted only within -30/+40 m of the floor cell), so badges do not float or
sink on slopes; wedge corners follow their own floor cells instead of staying
level with the apex. Unknown bearings never acquire a wedge. The selected
replaced once by the rendered surface under it (accepted only within -30/+40 m
of the floor cell), so badges do not float or sink on slopes; wedge corners
follow their own floor cells instead of staying level with the apex. The
surface is read outside the overlay paint, one camera per frame: a terrain
globe answers from its loaded mesh (`globe.getHeight`) and only photoreal
tiles are depth-sampled (`scene.sampleHeight`). Answers are kept per camera
until the map source changes, so a refresh or turning the layer off and on
samples nothing again. Unknown bearings never acquire a wedge. The selected
card's `DIRECTION` wraps after rounding to whole degrees, so a mapped bearing
of 359.5° or more reads `0°` rather than `360°`. The row reads
`N nearby · M on screen`; when every loaded camera is outside the view it adds
@@ -2949,13 +2953,7 @@ visits to one place share a key but does not bound how many distinct places a
session visits, and the cache previously had no eviction, so a long session
retained every coordinate it ever resolved. Eviction is least-recently-used:
a consumer read, including a warm hit in a batch lookup, promotes its entry, so
the cell being watched is not dropped for having been resolved early. A
synchronous read (`cachedEllipsoidalGround`, `cachedRealEllipsoidalGround`)
promotes its entry only once half the bound's worth of stores and promotions
have passed since the entry was last newest, so an entry read at least that
often is still never evicted while the per-frame floor reads no longer rewrite
the cache; those reads find entries through a numeric key and format no
strings. The bound sits far above a city-scale session, so
the cell being watched is not dropped for having been resolved early. The bound sits far above a city-scale session, so
normal use never evicts and issues no additional proxy requests. A batch's
results are assembled from what that call resolved or found warm, so eviction
during a large batch cannot report one of its points as unresolved. Re:Earth and
-34
View File
@@ -311,10 +311,6 @@ export const COAST_FLOOR_LIFT_M = 1.5;
*/
export function floorCoastedPosition(position, cachedGroundFloor) {
if (typeof cachedGroundFloor !== 'function') return position;
// Most coasting contacts are at cruise, and this runs for each of them on
// every fleet pass. One above COAST_FLOOR_CEILING_M cannot be lifted by any
// ground, so it needs neither the geodetic conversion nor the floor read.
if (clearOfAllGround(position)) return position;
const carto = Cesium.Cartographic.fromCartesian(
position,
Cesium.Ellipsoid.WGS84,
@@ -338,36 +334,6 @@ export function floorCoastedPosition(position, cachedGroundFloor) {
const _coastCarto = new Cesium.Cartographic();
/**
* @constant {number} No ground reaches this height above the ellipsoid:
* Everest's summit is about 8,821 m (8,849 m above a geoid that sits 28 m
* below the ellipsoid there).
*/
export const COAST_FLOOR_CEILING_M = 9000;
const _oneOverRadiiSq = Cesium.Ellipsoid.WGS84.oneOverRadiiSquared;
const COAST_CLEAR_SCALED_SQ =
(1 + COAST_FLOOR_CEILING_M / Cesium.Ellipsoid.WGS84.minimumRadius) ** 2;
/**
* Whether a position is certainly more than COAST_FLOOR_CEILING_M above the
* ellipsoid, from three multiplications. Scale the axes so the ellipsoid is
* the unit sphere: a point h metres up its surface normal is then at most
* 1 + h / (polar radius) from the centre, so one further out than
* 1 + ceiling / (polar radius) is above the ceiling. At the equator this
* answers yes from about 30 m above the ceiling; at the poles, from the
* ceiling itself.
* @param {Cesium.Cartesian3} position
* @returns {boolean}
*/
function clearOfAllGround(position) {
return (
position.x * position.x * _oneOverRadiiSq.x +
position.y * position.y * _oneOverRadiiSq.y +
position.z * position.z * _oneOverRadiiSq.z >
COAST_CLEAR_SCALED_SQ
);
}
/**
* Seconds to coast past the last report: the elapsed time, bounded by the
* source's coast limit and STALE_COAST_MAX_SEC; none for a contact that
-77
View File
@@ -10,7 +10,6 @@ import {
liftRepeatedGroundFix, synthesizeForwardKinematicsFix,
projectGroundArcLatLon, corridorPathLatLon,
COURSE_TRACK_ONLY_MPS, COURSE_CHORD_ONLY_MPS, COURSE_MIN_DPS, TURN_MIN_SPEED_MPS,
floorCoastedPosition, COAST_FLOOR_CEILING_M, COAST_FLOOR_LIFT_M,
} from './motionModel.js';
const AUSTIN = { lat: 30.2672, lon: -97.7431, alt: 9000 };
@@ -342,79 +341,3 @@ test('corridorPathLatLon returns nothing without a display position', () => {
extrapolating: true, displayLat: NaN, displayLon: -97.66, courseDeg: 0, speedMps: 10, lookaheadSec: 60,
}), []);
});
/** floorCoastedPosition as it was before the cruise skip: always converts and reads. */
function floorCoastedReference(position, groundAt) {
const carto = Cesium.Cartographic.fromCartesian(position);
if (!carto) return position;
const ground = groundAt(
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
);
if (!Number.isFinite(ground) || carto.height >= ground + COAST_FLOOR_LIFT_M)
return position;
return Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, ground + COAST_FLOOR_LIFT_M);
}
test('a coasting contact above any terrain is not looked up against the ground', () => {
let reads = 0;
const ground = () => {
reads += 1;
return 8821; // Everest's summit, the highest floor there is
};
// Just over the ceiling and at cruise, from pole to equator.
for (let lat = -90; lat <= 90; lat += 7.5) {
for (const lon of [0, 86.925, -78.8, 179.9]) {
for (const height of [COAST_FLOOR_CEILING_M + 35, 10_000, 12_500, 40_000]) {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, height);
const before = Cesium.Cartesian3.clone(position);
assert.equal(floorCoastedPosition(position, ground), position);
assert.ok(Cesium.Cartesian3.equals(position, before));
}
}
}
assert.equal(reads, 0);
});
test('below the ceiling a coast is floored exactly as before', () => {
// Terrain that rises under the coast, known in some places and not others.
const ground = (lat, lon) => (Math.floor(lat * 10 + lon) % 3 === 0 ? null : 1500 + ((lat * 7 + lon * 3) % 900));
let lifted = 0;
let checked = 0;
for (let lat = -89; lat <= 89; lat += 5.5) {
for (let lon = -180; lon < 180; lon += 17) {
for (const height of [-40, 0, 800, 1499, 1501.4, 2399, 2402, 5000, 8990, 9000, 9010, 11_000, 30_000]) {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, height);
const expected = floorCoastedReference(Cesium.Cartesian3.clone(position), ground);
const actual = floorCoastedPosition(position, ground);
assert.ok(
Cesium.Cartesian3.equals(actual, expected),
`coast at ${lat}, ${lon}, ${height} m`,
);
checked += 1;
if (!Cesium.Cartesian3.equals(expected, Cesium.Cartesian3.fromDegrees(lon, lat, height))) lifted += 1;
}
}
}
assert.ok(lifted > 500, `${lifted} of ${checked} coasts were lifted onto the ground`);
});
test('nothing at or below the ceiling skips the ground', () => {
let reads = 0;
const ground = () => {
reads += 1;
return null;
};
let asked = 0;
for (let lat = -90; lat <= 90; lat += 2.5) {
for (const height of [-400, 0, 4000, 8821, COAST_FLOOR_CEILING_M - 1, COAST_FLOOR_CEILING_M]) {
floorCoastedPosition(Cesium.Cartesian3.fromDegrees(33, lat, height), ground);
asked += 1;
}
}
assert.equal(reads, asked);
});
test('the ceiling clears the highest ground with room to spare', () => {
assert.ok(COAST_FLOOR_CEILING_M > 8849 + 100);
});
+139 -57
View File
@@ -4,10 +4,10 @@ import {
MARKER_ICON_SIZE,
SELECTED_MARKER_ICON_SIZE,
MARKER_FLOOR_LIFT_M,
ANCHOR_SAMPLES_PER_PAINT,
REMEMBERED_ANCHORS,
SURFACE_BELOW_FLOOR_M,
SURFACE_ABOVE_FLOOR_M,
SURFACE_SAMPLES_PER_PAINT,
SURFACE_SAMPLES_PER_FRAME,
} from './policy.js';
import {
MARKER_IMAGE,
@@ -19,11 +19,17 @@ import {
validAlprGroundHeight,
} from './visuals.js';
const nextAnimationFrame = (step) =>
typeof requestAnimationFrame === 'function'
? requestAnimationFrame(step)
: setTimeout(step, 16);
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
export function createAlprOverlay({
state,
services,
onSurfaceChange = () => {},
requestFrame = nextAnimationFrame,
}) {
let lane,
removeMapListener,
@@ -33,7 +39,12 @@ export function createAlprOverlay({
hits = [],
images = [],
destroyed = false,
lastCamera = null;
lastCamera = null,
sampling = false;
// Rendered-surface answers by camera id. Entities are rebuilt by a refresh
// and by turning the layer off and on; keeping the answers on them meant
// every rebuild sampled the same cameras again.
const surfaceAnswers = new Map();
const requestPaint = () => {
if (!destroyed && state.enabled) lane?.requestPaint();
};
@@ -92,38 +103,72 @@ export function createAlprOverlay({
entity.gevAlprDisplayPosition = null;
nativeVisible(entity, true);
}
surfaceAnswers.clear();
state.lastAnchorSampleAt = 0;
retryUntil = Date.now() + 15000;
requestPaint();
}
function anchorFor(record, entity, budget) {
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
function rememberSurface(record, height, checked) {
surfaceAnswers.delete(record.id);
surfaceAnswers.set(record.id, {
latitude: record.latitude,
longitude: record.longitude,
height,
checked,
});
if (surfaceAnswers.size > REMEMBERED_ANCHORS)
surfaceAnswers.delete(surfaceAnswers.keys().next().value);
}
/**
* Rendered surface height under a camera. A terrain globe answers from its
* loaded mesh; only photoreal tiles need `sampleHeight`, which renders depth
* and compiles its shaders on first use (a freeze of up to 1.9 s at load).
*/
function surfaceHeight(record, entity) {
const scene = state.viewer.scene;
const location = Cesium.Cartographic.fromDegrees(
record.longitude,
record.latitude,
);
// A floor-placed marker anchors where its native badge sits; only a
// marker still waiting for its floor samples the rendered surface, and
// each paint samples a few at most (each sample is a depth render).
if (scene.globe?.show) return scene.globe.getHeight?.(location);
if (!scene.sampleHeightSupported) return undefined;
try {
return scene.sampleHeight(location, [entity]);
} catch {
/* streaming tiles */
return undefined;
}
}
/** The anchor a paint can have without sampling, or null while it waits for one. */
function anchorFor(record, entity) {
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
const scene = state.viewer.scene;
const answer = surfaceAnswers.get(record.id);
if (
answer?.latitude === record.latitude &&
answer.longitude === record.longitude
) {
setAnchor(record, entity, answer.height);
if (answer.checked)
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
return entity.gevAlprCanvasPosition;
}
// A floor-placed marker anchors where its native badge sits. A marker
// still waiting for its floor reads the terrain globe, or on photoreal
// tiles waits for sampleAnchors().
let height;
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M;
else if (scene.sampleHeightSupported) {
if (budget.samples <= 0) return null;
budget.samples -= 1;
try {
height = scene.sampleHeight(location, [entity]);
} catch {
/* streaming tiles */
}
}
if (!validAlprGroundHeight(height) && scene.globe.show)
height = scene.globe.getHeight?.(location);
else if (scene.globe.show)
height = scene.globe.getHeight?.(
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
);
if (!validAlprGroundHeight(height)) return null;
setAnchor(record, entity, height);
return entity.gevAlprCanvasPosition;
@@ -158,14 +203,19 @@ export function createAlprOverlay({
}
/**
* Replace floor anchors with the rendered surface under each overlay camera.
* Floors come from ~111 m cells, so on a slope a badge could float or sink
* by tens of metres. Runs only while the camera is still and tiles have
* settled, a few depth samples per paint, and each anchor at most once;
* samples far outside the floor prior (a roof or a streaming tile) are
* rejected. Returns whether anchors remain to check.
* Sample the rendered surface for the overlay cameras, outside the paint.
*
* A camera with no floor yet gets its anchor from a sample. An anchored one
* is checked against the surface once: floors come from ~111 m cells, so on
* a slope a badge could float or sink by tens of metres. Checks run only
* while the camera is still and tiles have settled, and samples far outside
* the floor prior (a roof or a streaming tile) are rejected. Each frame
* takes SURFACE_SAMPLES_PER_FRAME samples, so no frame carries the whole
* cohort; sampling inside paint() put up to six depth renders in one frame.
*/
function refineAnchors(budget) {
function sampleAnchors() {
sampling = false;
if (destroyed || !state.enabled) return;
const camera = state.viewer.camera;
const scene = state.viewer.scene;
const still =
@@ -188,27 +238,41 @@ export function createAlprOverlay({
position: Cesium.Cartesian3.clone(camera.positionWC),
direction: Cesium.Cartesian3.clone(camera.directionWC),
};
if (!scene.sampleHeightSupported) return false;
let pending = false;
const canSample = scene.globe?.show || scene.sampleHeightSupported;
let samples = SURFACE_SAMPLES_PER_FRAME,
pending = false,
progressed = false,
moved = false;
// Cameras with no anchor come first: they are not on the overlay yet.
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
if (!entity || entity.gevAlprCanvasPosition) continue;
// The floor may have resolved since the paint that found none.
if (anchorFor(record, entity)) moved = true;
else if (!canSample || samples <= 0) pending = true;
else {
samples -= 1;
const sample = surfaceHeight(record, entity);
if (validAlprGroundHeight(sample)) {
setAnchor(record, entity, sample);
rememberSurface(record, sample, false);
moved = progressed = true;
} else pending = true;
}
}
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
const anchor = entity?.gevAlprCanvasPosition;
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
if (!anchor || !canSample || entity.gevAlprSurfaceCheckedFor === anchor)
continue;
if (!still || !surfaceSettled(scene) || samples <= 0) {
pending = true;
continue;
}
budget.samples -= 1;
let sample;
try {
sample = scene.sampleHeight(
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
[entity],
);
} catch {
/* streaming tiles */
}
const current = Cesium.Cartographic.fromCartesian(anchor).height;
samples -= 1;
progressed = true;
const sample = surfaceHeight(record, entity);
let height = Cesium.Cartographic.fromCartesian(anchor).height;
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
@@ -218,21 +282,43 @@ export function createAlprOverlay({
(!validAlprGroundHeight(floor) ||
(sample >= floor - SURFACE_BELOW_FLOOR_M &&
sample <= floor + SURFACE_ABOVE_FLOOR_M));
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1)
setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M);
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - height) > 1) {
height = sample + MARKER_FLOOR_LIFT_M;
setAnchor(record, entity, height);
moved = true;
}
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
// A surface that did not answer is asked again after a rebuild.
if (validAlprGroundHeight(sample)) rememberSurface(record, height, true);
if (entity.id === state.selectedId)
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
}
return pending;
if (moved) requestPaint();
if (!pending || Date.now() >= retryUntil) return;
// Take the next ration on the next frame; when nothing could be sampled
// (camera moving, tiles streaming, no floor yet) look again in 250 ms.
if (progressed) scheduleSampling();
else if (!retryTimer)
retryTimer = setTimeout(() => {
retryTimer = null;
scheduleSampling();
}, 250);
}
function scheduleSampling() {
if (sampling || destroyed) return;
sampling = true;
requestFrame(sampleAnchors);
}
function paint({ ctx, width, height, keyhole, occluder }) {
hits = [];
if (!state.enabled || destroyed) return;
let unresolved = false;
let waiting = false;
const painted = [];
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
const canSample =
state.viewer.scene.globe?.show ||
state.viewer.scene.sampleHeightSupported;
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
if (!entity) continue;
@@ -246,12 +332,14 @@ export function createAlprOverlay({
nativeVisible(entity, true);
continue;
}
const anchor = anchorFor(record, entity, budget);
const anchor = anchorFor(record, entity);
if (!anchor) {
nativeVisible(entity, true);
unresolved = true;
waiting = true;
continue;
}
if (canSample && entity.gevAlprSurfaceCheckedFor !== anchor)
waiting = true;
canvasOwned(entity, true);
if (occluder && !occluder.isPointVisible(anchor)) {
setShown(entity, false);
@@ -335,15 +423,9 @@ export function createAlprOverlay({
ctx.restore();
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
}
const refining = refineAnchors({
samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT),
});
if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) {
retryTimer = setTimeout(() => {
retryTimer = null;
requestPaint();
}, 250);
}
// Anchors still to find or to check are sampled after this paint, a few
// per frame, never inside it.
if (waiting && !retryTimer && Date.now() < retryUntil) scheduleSampling();
}
return {
+135 -19
View File
@@ -45,8 +45,10 @@ function harness(t, count = 2, { floor = null } = {}) {
active,
removed = 0,
paints = 0;
const frames = [];
const overlay = createAlprOverlay({
state,
requestFrame: (step) => frames.push(step),
services: {
groundFloor: { cachedGroundFloor: () => floor },
overlays: {
@@ -106,6 +108,12 @@ function harness(t, count = 2, { floor = null } = {}) {
entities,
draw,
paint: (occluder) => paint({ ctx, width: 800, height: 600, occluder }),
/** Run the sampling step queued for the next animation frame, if any. */
frame() {
const step = frames.shift();
step?.();
return Boolean(step);
},
mapChanged() {
mapListener({ detail: { status: 'ready' } });
},
@@ -243,41 +251,149 @@ test('overlay glyphs and native badges shrink alike with distance', () => {
assert.ok(Math.abs(markerScale(20_250) - 0.8) < 1e-9);
});
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
const h = harness(t, 2, { floor: 150 });
/** A photoreal scene: no globe, the rendered surface read by depth samples. */
function photoreal(h, heightAt = () => 172) {
const scene = h.state.viewer.scene;
const camera = h.state.viewer.camera;
camera.directionWC = Cesium.Cartesian3.UNIT_X;
h.state.viewer.camera.directionWC = Cesium.Cartesian3.UNIT_X;
scene.globe.show = false;
scene.sampleHeightSupported = true;
scene.primitives = { length: 0, get: () => null };
let samples = 0;
const sampled = [];
scene.sampleHeight = (carto) => {
samples++;
return Cesium.Math.toDegrees(carto.latitude) > 30.00005 ? 400 : 172;
sampled.push(carto);
return heightAt(Cesium.Math.toDegrees(carto.latitude));
};
return sampled;
}
const anchorHeight = (h, id) =>
Cesium.Cartographic.fromCartesian(
h.entities.getById(id).gevAlprCanvasPosition,
).height;
/** Run sampling frames until none is queued, waiting out the 250 ms look-again. */
function settle(t, h) {
for (let i = 0; i < 400; i++) if (!h.frame()) t.mock.timers.tick(250);
}
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 2, { floor: 150 });
const sampled = photoreal(h, (latitude) => (latitude > 30.00005 ? 400 : 172));
h.overlay.sync(h.records);
h.paint();
const height = (id) =>
Cesium.Cartographic.fromCartesian(
h.entities.getById(id).gevAlprCanvasPosition,
).height;
assert.ok(
Math.abs(anchorHeight(h, 'camera:0') - 151.5) < 0.01,
'floor anchor first',
);
assert.equal(h.frame(), true, 'the paint queued a sampling frame');
assert.equal(
samples,
sampled.length,
0,
'no depth samples while the camera may still be moving',
);
assert.ok(Math.abs(height('camera:0') - 151.5) < 0.01, 'floor anchor first');
h.paint();
settle(t, h);
assert.ok(
Math.abs(height('camera:0') - 173.5) < 0.01,
Math.abs(anchorHeight(h, 'camera:0') - 173.5) < 0.01,
'rendered surface once still',
);
assert.ok(
Math.abs(height('camera:1') - 151.5) < 0.01,
Math.abs(anchorHeight(h, 'camera:1') - 151.5) < 0.01,
'a rooftop-height sample is rejected',
);
const taken = samples;
assert.equal(sampled.length, 2);
h.paint();
assert.equal(samples, taken, 'each anchor is checked once');
settle(t, h);
assert.equal(sampled.length, 2, 'each anchor is checked once');
});
test('a paint never samples the surface; frames take one sample each', (t) => {
// Sampling inside paint() put up to six depth renders into one frame, and
// the first of them compiles shaders: a freeze of 0.8 to 1.9 s at load.
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 8);
const sampled = photoreal(h);
h.overlay.sync(h.records);
h.paint();
h.paint();
assert.equal(sampled.length, 0, 'no sample inside a paint');
assert.equal(h.draw.length, 0, 'unanchored cameras keep their native badge');
for (let taken = 1; taken <= 8; taken++) {
assert.equal(h.frame(), true);
assert.equal(sampled.length, taken, 'one sample per frame');
}
for (const record of h.records)
assert.ok(Math.abs(anchorHeight(h, record.id) - 172) < 0.01);
h.paint();
assert.ok(h.draw.length > 0, 'anchored cameras paint on the overlay');
});
test('a terrain globe is read from its mesh, never depth-sampled', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 3, { floor: 150 });
const sampled = photoreal(h);
const scene = h.state.viewer.scene;
scene.globe.show = true;
scene.globe.tilesLoaded = true;
let reads = 0;
scene.globe.getHeight = () => {
reads++;
return 172;
};
h.overlay.sync(h.records);
h.paint();
settle(t, h);
assert.equal(sampled.length, 0);
assert.equal(reads, 3);
// The same anchor the depth sample gave: the surface plus the marker lift.
for (const record of h.records)
assert.ok(Math.abs(anchorHeight(h, record.id) - 173.5) < 0.01);
});
test('surface answers outlive rebuilt markers and a re-enable, until the map changes', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = harness(t, 4, { floor: 150 });
const sampled = photoreal(h);
h.overlay.sync(h.records);
h.paint();
settle(t, h);
assert.equal(sampled.length, 4);
const before = h.records.map((record) => anchorHeight(h, record.id));
// A refresh or a toggle removes the entities and builds new ones.
const rebuild = () => {
h.overlay.clear();
h.entities.removeAll();
for (const record of h.records)
h.entities.add({
id: record.id,
billboard: { image: 'fallback', show: true },
polyline: { show: true },
polygon: { show: true },
});
h.overlay.sync(h.records);
h.paint();
settle(t, h);
};
rebuild();
h.state.enabled = false;
h.overlay.clear();
h.state.enabled = true;
rebuild();
assert.equal(sampled.length, 4, 'no camera is sampled again');
assert.deepEqual(
h.records.map((record) => anchorHeight(h, record.id)),
before,
);
// A camera that moved is a different question.
h.records[0] = { ...h.records[0], latitude: h.records[0].latitude + 0.001 };
rebuild();
assert.equal(sampled.length, 5);
h.mapChanged();
h.paint();
settle(t, h);
assert.equal(sampled.length, 9, 'a new map surface is sampled afresh');
});
+5 -4
View File
@@ -66,14 +66,15 @@ export const MAX_CANVAS_FRUSTUMS = 64;
/** Metres a floor-placed marker sits above its resolved ground cell. */
export const MARKER_FLOOR_LIFT_M = 1.5;
/** Rendered-surface samples one overlay paint may take (each is a depth render). */
export const ANCHOR_SAMPLES_PER_PAINT = 4;
/** Rendered-surface samples one frame may take (on photoreal tiles each is a
* depth render of several milliseconds, so they are spread over frames). */
export const SURFACE_SAMPLES_PER_FRAME = 1;
/** Surface answers kept by camera id, so a refresh or re-enable asks nothing. */
export const REMEMBERED_ANCHORS = 2048;
/** Accepted rendered-surface window around a floor cell (a cut or an embankment
* sits within it; a rooftop or an unstreamed tile usually does not). */
export const SURFACE_BELOW_FLOOR_M = 30;
/** Rendered-surface refinements per paint while the camera is still. */
export const SURFACE_SAMPLES_PER_PAINT = 2;
export const SURFACE_ABOVE_FLOOR_M = 40;
/** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */
+11 -55
View File
@@ -45,39 +45,6 @@ export function createGroundFloor({ terrain, signal }) {
return { lat: Number(lat.toFixed(3)), lon: Number(lon.toFixed(3)) };
}
/**
* One coordinate's coarse cell as an integer: `Number(deg.toFixed(3))` in
* thousandths of a degree, without the trip through a string. The floor is
* read for every aircraft on every fleet pass, and `coarseFloorCoord`'s two
* `toFixed` calls and the string keys built from them were most of the cost.
* `toFixed` rounds the exact decimal value; `deg * 1000` is within 1e-10 of
* it for |deg| < 1000, so the two can only round differently right at a
* half, and there the string decides.
* @param {number} deg
* @returns {number}
*/
function coarseCellIndex(deg) {
const scaled = deg * 1000;
if (
!(Math.abs(deg) < 1000) ||
Math.abs(scaled - Math.floor(scaled) - 0.5) < 1e-6
)
return Math.round(Number(deg.toFixed(3)) * 1000);
return Math.round(scaled);
}
/**
* Key of a coarse cell in `_meshCells`: the two indices packed into one
* number, or the text form for a longitude too far out of range to pack.
* @param {number} latIndex @param {number} lonIndex
* @returns {number|string}
*/
function meshCellKey(latIndex, lonIndex) {
return Math.abs(lonIndex) < 500_000 && Math.abs(latIndex) < 1e9
? latIndex * 1_000_000 + lonIndex
: `${latIndex},${lonIndex}`;
}
/**
* Pure floor clamp.
* - finite alt + finite ground → max(alt, ground + lift)
@@ -457,8 +424,8 @@ export function createGroundFloor({ terrain, signal }) {
// 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<number|string, Array<{lat: number, lon: number, h: number}>>}
* coarse cell key (`meshCellKey`) -> rendered-surface readings (m, ellipsoidal), each kept
/** @type {Map<string, Array<{lat: number, lon: number, h: number}>>}
* 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. */
@@ -501,7 +468,8 @@ export function createGroundFloor({ terrain, signal }) {
!Number.isFinite(heightM)
)
return;
const key = meshCellKey(coarseCellIndex(lat), coarseCellIndex(lon));
const c = coarseFloorCoord(lat, lon);
const key = `${c.lat},${c.lon}`;
if (!_meshCells.has(key)) _meshCells.set(key, [{ lat, lon, h: heightM }]);
}
@@ -529,7 +497,7 @@ export function createGroundFloor({ terrain, signal }) {
const cell = coarseFloorCoord(lat, lon);
const prior = cachedRealEllipsoidalGround(cell.lat, cell.lon);
if (!meshFloorSampleWithinPrior(heightM, prior)) return false;
const key = meshCellKey(coarseCellIndex(lat), coarseCellIndex(lon));
const key = `${cell.lat},${cell.lon}`;
const readings = _meshCells.get(key);
if (!readings) {
_meshCells.set(key, [{ lat, lon, h: heightM }]);
@@ -570,16 +538,8 @@ export function createGroundFloor({ terrain, signal }) {
function cachedMeshFloor(lat, lon) {
if (!_meshPreferred) return null;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return meshFloorInCell(
lat,
lon,
meshCellKey(coarseCellIndex(lat), coarseCellIndex(lon)),
);
}
/** `cachedMeshFloor` for a caller that already has the point's cell key. */
function meshFloorInCell(lat, lon, key) {
const readings = _meshCells.get(key);
const c = coarseFloorCoord(lat, lon);
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;
@@ -612,14 +572,10 @@ export function createGroundFloor({ terrain, signal }) {
*/
function cachedGroundFloor(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
// The cell is worked out once, as integers, for both reads.
const latIndex = coarseCellIndex(lat);
const lonIndex = coarseCellIndex(lon);
if (_meshPreferred) {
const mesh = meshFloorInCell(lat, lon, meshCellKey(latIndex, lonIndex));
if (mesh != null) return mesh;
}
return cachedRealEllipsoidalGround(latIndex / 1000, lonIndex / 1000);
const mesh = cachedMeshFloor(lat, lon);
if (mesh != null) return mesh;
const c = coarseFloorCoord(lat, lon);
return cachedRealEllipsoidalGround(c.lat, c.lon);
}
/**
-176
View File
@@ -1,176 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createGroundFloor } from './groundFloor.js';
import { createTerrainHeights } from './terrainHeights.js';
/** Counts Number.prototype.toFixed calls made while `run` executes. */
function countToFixed(run) {
const original = Number.prototype.toFixed;
let calls = 0;
Number.prototype.toFixed = function (...args) {
calls += 1;
return original.apply(this, args);
};
try {
run();
} finally {
Number.prototype.toFixed = original;
}
return calls;
}
/**
* The floor read as it was before the cell became an integer: the coarse cell
* through `toFixed(3)`, mesh cells and DEM cells under string keys.
*/
function referenceFloor() {
const mesh = new Map();
const dem = new Map();
const coarse = (lat, lon) => ({
lat: Number(lat.toFixed(3)),
lon: Number(lon.toFixed(3)),
});
const demKey = (lat, lon) => `${lat.toFixed(5)},${lon.toFixed(5)}`;
return {
reportMesh(lat, lon, h) {
const c = coarse(lat, lon);
const key = `${c.lat},${c.lon}`;
if (!mesh.has(key)) mesh.set(key, [{ lat, lon, h }]);
},
storeDem(lat, lon, h) {
dem.set(demKey(lat, lon), h);
},
read(lat, lon, meshPreferred = true) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const c = coarse(lat, lon);
const readings = meshPreferred ? mesh.get(`${c.lat},${c.lon}`) : null;
if (readings) return readings[0].h;
return dem.get(demKey(c.lat, c.lon)) ?? null;
},
};
}
/** A real terrain cache whose every resolved point takes the height asked for. */
async function warmTerrain(points) {
const heights = new Map(points.map((p) => [`${p.lat},${p.lon}`, p.h]));
const terrain = createTerrainHeights({
source: {
async getHeights(chunk) {
return chunk.map((item) => ({
ellipsoid: heights.get(`${item.lat},${item.lon}`),
}));
},
},
});
await terrain.resolveEllipsoidalGround(
points.map(({ lat, lon }) => ({ lat, lon })),
);
return terrain;
}
/** Deterministic pseudo-random numbers in [0, 1). */
function random(seed) {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) % 4294967296;
return state / 4294967296;
};
}
/** Coordinates that land on, and just either side of, a coarse-cell edge. */
function edgeCoordinates() {
const out = [];
for (const base of [0, 0.0005, -0.0005, 12.3455, -12.3455, 89.9995, 179.9995])
for (const nudge of [0, 1e-13, -1e-13, 1e-9, -1e-9, 1e-7, -1e-7, 4e-4])
out.push(base + nudge);
// Thousandths whose doubles sit a hair either side of the half.
for (let k = -40; k <= 40; k += 1) out.push((k + 0.5) / 1000);
out.push(-0, 1.0005, 1.0015, 2.5, -2.5, 1e-7, -1e-7);
return out;
}
test('the floor read answers exactly as the string-keyed read did', async () => {
const next = random(20261113);
const cells = [];
for (let i = 0; i < 400; i += 1) {
cells.push({
lat: Number((next() * 170 - 85).toFixed(3)),
lon: Number((next() * 360 - 180).toFixed(3)),
h: Math.round(next() * 4000),
});
}
// Cells along the equator and the prime meridian, where a cell index is 0.
for (const edge of edgeCoordinates()) {
const lat = Number(edge.toFixed(3));
cells.push({ lat: Math.max(-89, Math.min(89, lat)), lon: 10, h: 77 });
cells.push({ lat: 10, lon: lat, h: 88 });
}
const reference = referenceFloor();
for (const c of cells) reference.storeDem(c.lat, c.lon, c.h);
const terrain = await warmTerrain(cells);
const groundFloor = createGroundFloor({ terrain });
// A rendered-surface reading in every seventh cell, taken off its centre.
cells.forEach((c, i) => {
if (i % 7) return;
const lat = c.lat + 0.0002;
const lon = c.lon - 0.0003;
reference.reportMesh(lat, lon, c.h + 3);
groundFloor.reportMeshFloorCell(lat, lon, c.h + 3);
});
const probes = [];
for (const c of cells) {
probes.push([c.lat, c.lon]);
probes.push([c.lat + (next() - 0.5) * 0.002, c.lon + (next() - 0.5) * 0.002]);
}
for (const a of edgeCoordinates())
for (const b of [10, 10.0004, -0, 0.0005, -0.0005]) {
probes.push([a, b]);
probes.push([b, a]);
}
probes.push([NaN, 1], [1, Infinity], [95, 400], [10, 720.1234]);
for (const meshPreferred of [true, false]) {
groundFloor.setMeshFloorPreferred(meshPreferred);
let hits = 0;
for (const [lat, lon] of probes) {
const expected = reference.read(lat, lon, meshPreferred);
assert.equal(
groundFloor.cachedGroundFloor(lat, lon),
expected,
`floor at ${lat}, ${lon} (mesh ${meshPreferred})`,
);
if (expected !== null) hits += 1;
}
assert.ok(hits > 400, `${hits} probes found a floor`);
}
});
test('reading a floor formats no numbers, hit or miss', async () => {
const cell = { lat: 51.47, lon: -0.454, h: 25 };
const terrain = await warmTerrain([cell]);
const groundFloor = createGroundFloor({ terrain });
groundFloor.reportMeshFloorCell(48.3537, 11.7861, 453);
const calls = countToFixed(() => {
// The DEM cell, a rendered-surface cell, and open ocean with neither.
assert.equal(groundFloor.cachedGroundFloor(51.4702, -0.4541), 25);
assert.equal(groundFloor.cachedGroundFloor(48.3538, 11.7862), 453);
for (let i = 0; i < 1000; i += 1)
assert.equal(groundFloor.cachedGroundFloor(30 + i * 0.011, -40.2), null);
});
// Before, each read made six toFixed calls (the cell twice, the DEM key).
assert.equal(calls, 0);
});
test('a reading reported at a point is found from anywhere in its cell', async () => {
const terrain = await warmTerrain([{ lat: 41.5, lon: -97.66, h: 120 }]);
const groundFloor = createGroundFloor({ terrain });
assert.equal(groundFloor.reportValidatedMeshFloorCell(41.5003, -97.6602, 129), true);
assert.equal(groundFloor.cachedMeshFloor(41.4996, -97.6598), 129);
assert.equal(groundFloor.cachedGroundFloor(41.4996, -97.6598), 129);
// The next cell over has no reading and no DEM entry.
assert.equal(groundFloor.cachedMeshFloor(41.5006, -97.6602), null);
assert.equal(groundFloor.cachedGroundFloor(41.5006, -97.6602), null);
});
+3 -125
View File
@@ -86,89 +86,6 @@ export function createTerrainHeights({
*/
const cache = new Map();
/**
* The same entries under a numeric key, for the synchronous reads. Those run
* for every aircraft on every fleet pass (about 94,000 a second with 7,500
* aircraft in view), nearly all of them misses, and building the string key
* (two `toFixed` calls and a concatenation) was most of what a read cost.
* Kept in step with `cache` by `cacheStore`, eviction and the abort handler.
* `at` is the value of `moves` when the entry last became the newest.
* @type {Map<number, {key: string, entry: object, at: number}>}
*/
const cells = new Map();
/** Counts every move to the newest end of `cache`: stores and promotions. */
let moves = 0;
/**
* A read promotes its entry only once this many moves have passed since it
* was last the newest. An entry cannot reach the cold end in fewer than
* CACHE_MAX_ENTRIES - 1 moves, so one read at least this often is never
* evicted, without rewriting the Map on every read.
*/
const PROMOTE_AFTER_MOVES = Math.floor(CACHE_MAX_ENTRIES / 2);
/**
* `value.toFixed(5)` as an integer count of 1e-5 steps, or null when the
* fast arithmetic cannot be trusted to match it. `toFixed` rounds the exact
* decimal value; `value * 1e5` is within 2e-8 of it for |value| < 1000, so
* the two can only round differently this close to a half. A negative value
* that rounds to zero is left to the string too: it formats as "-0.00000",
* a different key from "0.00000".
* @param {number} value
* @returns {number|null}
*/
function fixed5Index(value) {
if (!(Math.abs(value) < 1000)) return null;
const scaled = value * 1e5;
if (Math.abs(scaled - Math.floor(scaled) - 0.5) < 1e-6) return null;
const index = Math.round(scaled);
return Object.is(index, -0) ? null : index;
}
/** Indices at or past this (500 degrees) do not pack into one integer. */
const CELL_INDEX_LIMIT = 5e7;
/**
* The numeric key for a coordinate: its two 5-decimal indices packed into
* one integer (below 2^53), or null when the coordinate has to go through
* its string key instead.
* @param {number} lat
* @param {number} lon
* @returns {number|null}
*/
function cellKey(lat, lon) {
const latIndex = fixed5Index(lat);
const lonIndex = fixed5Index(lon);
if (
latIndex === null ||
lonIndex === null ||
Math.abs(latIndex) >= CELL_INDEX_LIMIT ||
Math.abs(lonIndex) >= CELL_INDEX_LIMIT
)
return null;
return latIndex * 1e8 + lonIndex;
}
/**
* The numeric key of a stored string key. The text is the rounded
* coordinate itself, so this is exact. Null for a key `cellKey` never
* produces; such a key is only ever read through its string.
* @param {string} key
* @returns {number|null}
*/
function cellKeyOf(key) {
const comma = key.indexOf(',');
const latIndex = Math.round(Number(key.slice(0, comma)) * 1e5);
const lonIndex = Math.round(Number(key.slice(comma + 1)) * 1e5);
if (
!(Math.abs(latIndex) < CELL_INDEX_LIMIT) ||
!(Math.abs(lonIndex) < CELL_INDEX_LIMIT) ||
Object.is(latIndex, -0) ||
Object.is(lonIndex, -0)
)
return null;
return latIndex * 1e8 + lonIndex;
}
/**
* Store an entry and drop the coldest ones once past the cap. `Map` iterates
* in insertion order, so deleting the first key evicts the least recently
@@ -184,15 +101,10 @@ export function createTerrainHeights({
function cacheStore(key, entry) {
cache.delete(key);
cache.set(key, entry);
moves += 1;
const cell = cellKeyOf(key);
if (cell !== null) cells.set(cell, { key, entry, at: moves });
while (cache.size > CACHE_MAX_ENTRIES) {
const coldest = cache.keys().next().value;
if (coldest === undefined) break;
cache.delete(coldest);
const coldestCell = cellKeyOf(coldest);
if (coldestCell !== null) cells.delete(coldestCell);
}
}
@@ -207,36 +119,9 @@ export function createTerrainHeights({
if (entry === undefined) return undefined;
cache.delete(key);
cache.set(key, entry);
moves += 1;
const cell = cellKeyOf(key);
const held = cell === null ? undefined : cells.get(cell);
if (held) held.at = moves;
return entry;
}
/**
* The synchronous read: the entry for a coordinate, or undefined on a miss.
* Same answer as `cacheTouch(cacheKey(lat, lon))`, without building the
* string, and promoting only an entry that has drifted towards the cold end
* (see PROMOTE_AFTER_MOVES).
* @param {number} lat
* @param {number} lon
* @returns {{ellipsoid: number, source: 'reearth'|'geoid-fallback', retryAt?: number}|undefined}
*/
function cacheRead(lat, lon) {
const cell = cellKey(lat, lon);
if (cell === null) return cacheTouch(cacheKey(lat, lon));
const held = cells.get(cell);
if (held === undefined) return undefined;
if (moves - held.at > PROMOTE_AFTER_MOVES) {
cache.delete(held.key);
cache.set(held.key, held.entry);
moves += 1;
held.at = moves;
}
return held.entry;
}
/**
* Builds the rounded cache key shared between the in-memory cache and the
* `points` string sent to the proxy (see file header, point 2).
@@ -257,7 +142,7 @@ export function createTerrainHeights({
* @returns {number|null}
*/
function cachedEllipsoidalGround(lat, lon) {
const entry = cacheRead(lat, lon);
const entry = cacheTouch(cacheKey(lat, lon));
return entry ? entry.ellipsoid : null;
}
@@ -274,7 +159,7 @@ export function createTerrainHeights({
* @returns {number|null}
*/
function cachedRealEllipsoidalGround(lat, lon) {
const entry = cacheRead(lat, lon);
const entry = cacheTouch(cacheKey(lat, lon));
return entry && entry.source === 'reearth' ? entry.ellipsoid : null;
}
@@ -485,14 +370,7 @@ export function createTerrainHeights({
});
}
signal?.addEventListener(
'abort',
() => {
cache.clear();
cells.clear();
},
{ once: true },
);
signal?.addEventListener('abort', () => cache.clear(), { once: true });
return {
cachedEllipsoidalGround,
cachedRealEllipsoidalGround,
-148
View File
@@ -169,151 +169,3 @@ test('an out-of-range cache bound falls back to the default instead of disabling
);
}
});
/** Counts Number.prototype.toFixed calls made while `run` executes. */
function countToFixed(run) {
const original = Number.prototype.toFixed;
let calls = 0;
Number.prototype.toFixed = function (...args) {
calls += 1;
return original.apply(this, args);
};
try {
run();
} finally {
Number.prototype.toFixed = original;
}
return calls;
}
test('a synchronous read finds exactly the entry its 5-decimal key names', async () => {
// Stored coordinates, and for each the reads that must and must not reach it.
// The expectation is the string key the cache has always used.
const stored = [
[0, 0.001],
[51.47, -0.454],
[-33.94611, 151.17722],
[0.00001, 0.00002],
[-0.00001, 0.00003],
[89.99999, 179.99999],
[-89.99999, -179.99999],
[12.345675, 98.765435],
[1e-7, 5],
[-1e-7, 6],
[7, -1e-7],
[200.5, 361.25],
[10, 500],
[10, 499.999999],
];
const key = (lat, lon) => `${lat.toFixed(5)},${lon.toFixed(5)}`;
const heights = new Map(
stored.map(([lat, lon], i) => [key(lat, lon), 100 + i]),
);
const terrain = createTerrainHeights({
source: {
async getHeights(chunk) {
return chunk.map((item) => ({ ellipsoid: heights.get(item.key) }));
},
},
});
await terrain.resolveEllipsoidalGround(
stored.map(([lat, lon]) => ({ lat, lon })),
);
const probes = [];
const nudges = [
0, 1e-13, -1e-13, 1e-9, -1e-9, 4e-6, -4e-6, 4.9999e-6, 5e-6, -5e-6,
5.0001e-6, 1e-5, -1e-5,
];
for (const [lat, lon] of stored)
for (const a of nudges)
for (const b of [0, 4e-6, -5e-6])
probes.push([lat + a, lon + b], [lat + b, lon + a]);
probes.push([0, 0], [-0, 0.001], [0, -0], [NaN, 1], [1, Infinity], [1e21, 2]);
let hits = 0;
for (const [lat, lon] of probes) {
const expected = heights.get(key(lat, lon)) ?? null;
assert.equal(
terrain.cachedEllipsoidalGround(lat, lon),
expected,
`plain read at ${lat}, ${lon}`,
);
assert.equal(
terrain.cachedRealEllipsoidalGround(lat, lon),
expected,
`real read at ${lat}, ${lon}`,
);
if (expected !== null) hits += 1;
}
assert.ok(
hits > 100 && hits < probes.length,
`${hits} of ${probes.length} reads hit`,
);
});
test('a synchronous read of an ordinary coordinate formats no numbers', async () => {
const terrain = createTerrainHeights({ source: stubSource() });
await terrain.resolveEllipsoidalGround([pointAt(1), pointAt(2)]);
const calls = countToFixed(() => {
assert.equal(
terrain.cachedRealEllipsoidalGround(0, lonAt(1)),
heightAt(lonAt(1)),
);
assert.equal(
terrain.cachedEllipsoidalGround(0, lonAt(2)),
heightAt(lonAt(2)),
);
// Misses are the common case: an aircraft over a cell nobody resolved.
for (let i = 10; i < 1010; i += 1)
assert.equal(terrain.cachedRealEllipsoidalGround(45.123, lonAt(i)), null);
});
// Before, every read built its key with two toFixed calls.
assert.equal(calls, 0);
});
test('an entry read again and again is not rewritten on each read, and still outlives the rest', async () => {
const terrain = createTerrainHeights({
source: stubSource(),
maxCacheEntries: 8,
});
for (let i = 0; i < 8; i += 1)
await terrain.resolveEllipsoidalGround([pointAt(i)]);
// Coordinate 0 is the coldest. Read it between stores, as a parked contact
// would; the seven newer ones are never read.
for (let i = 8; i < 15; i += 1) {
for (let read = 0; read < 50; read += 1)
assert.equal(
terrain.cachedEllipsoidalGround(0, lonAt(0)),
heightAt(lonAt(0)),
);
await terrain.resolveEllipsoidalGround([pointAt(i)]);
}
assert.equal(
terrain.cachedEllipsoidalGround(0, lonAt(0)),
heightAt(lonAt(0)),
);
for (let i = 1; i < 8; i += 1)
assert.equal(
terrain.cachedEllipsoidalGround(0, lonAt(i)),
null,
`unread coordinate ${i} was evicted`,
);
});
test('an evicted coordinate is gone from the synchronous read too', async () => {
const terrain = createTerrainHeights({
source: stubSource(),
maxCacheEntries: 2,
});
for (let i = 0; i < 5; i += 1)
await terrain.resolveEllipsoidalGround([pointAt(i)]);
for (let i = 0; i < 3; i += 1)
assert.equal(terrain.cachedRealEllipsoidalGround(0, lonAt(i)), null);
for (let i = 3; i < 5; i += 1)
assert.equal(
terrain.cachedRealEllipsoidalGround(0, lonAt(i)),
heightAt(lonAt(i)),
);
});