mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc5718bb82 | ||
|
|
f47313ff3b |
No files matched your search
@@ -1,4 +0,0 @@
|
||||
- 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).
|
||||
@@ -0,0 +1,3 @@
|
||||
- The AIS vessel store no longer walks every vessel for each position message.
|
||||
Expiry runs on the existing 15 s tick, the cap evicts in update order, and a
|
||||
vessel's static data is dropped with it (mazy1998, #1712).
|
||||
@@ -1513,14 +1513,10 @@ 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 (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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
readAisTrack,
|
||||
aisStreamRows,
|
||||
newestAisPositionAt,
|
||||
pruneAisStreamCache,
|
||||
} from './ais-store.js';
|
||||
// ---------------------------------------------------------------------------
|
||||
// AISStream live vessel cache state
|
||||
@@ -338,6 +339,9 @@ function startAisStreamWatchdogTick() {
|
||||
} catch (error) {
|
||||
console.warn('[AISStream] watchdog tick failed', error?.message || '');
|
||||
}
|
||||
// The store no longer prunes per message; this tick is its expiry clock,
|
||||
// so a silent feed still releases vessels it stopped hearing from.
|
||||
pruneAisStreamCache();
|
||||
}, AISSTREAM_TICK_MS);
|
||||
_aisStreamTickTimer.unref?.();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ const _aisStreamStatic = new Map();
|
||||
const _aisStreamTracks = new Map();
|
||||
/** @type {Map<string,{lat:number,lon:number,epochSec:number}>} mmsi -> first fix awaiting second (lazy buffer allocation) */
|
||||
const _aisStreamTrackPending = new Map();
|
||||
/** Full prune walks so far; diagnostics only (see aisStreamCacheStats). */
|
||||
let _aisStreamPruneWalks = 0;
|
||||
|
||||
/**
|
||||
* Store one parsed AIS envelope.
|
||||
@@ -105,6 +107,10 @@ export function ingestAisStreamEnvelope(envelope) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
|
||||
|
||||
const staticData = _aisStreamStatic.get(mmsi) || {};
|
||||
// Re-insert rather than overwrite: a Map iterates in insertion order, so
|
||||
// this keeps the least recently updated vessel first and the cap can evict
|
||||
// from the front instead of sorting every row.
|
||||
_aisStreamVessels.delete(mmsi);
|
||||
_aisStreamVessels.set(mmsi, {
|
||||
lat,
|
||||
lon,
|
||||
@@ -132,10 +138,22 @@ export function ingestAisStreamEnvelope(envelope) {
|
||||
aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc),
|
||||
);
|
||||
|
||||
pruneAisStreamCache();
|
||||
// Only the cap is enforced per message, and that is O(1). Expiry needs a
|
||||
// walk of every row, so it runs on the caller's timer (pruneAisStreamCache);
|
||||
// readers check age themselves in between.
|
||||
while (_aisStreamVessels.size > AISSTREAM_CACHE_MAX)
|
||||
dropAisVessel(_aisStreamVessels.keys().next().value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Forget one vessel entirely, so no side map can outgrow the vessel map. */
|
||||
function dropAisVessel(mmsi) {
|
||||
_aisStreamVessels.delete(mmsi);
|
||||
_aisStreamStatic.delete(mmsi);
|
||||
_aisStreamTracks.delete(mmsi);
|
||||
_aisStreamTrackPending.delete(mmsi);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an AISStream UTC timestamp into epoch seconds (fallback: now).
|
||||
*/
|
||||
@@ -157,7 +175,12 @@ function appendAisTrackSample(mmsi, lat, lon, epochSec) {
|
||||
let track = _aisStreamTracks.get(mmsi);
|
||||
if (!track) {
|
||||
const pending = _aisStreamTrackPending.get(mmsi);
|
||||
if (!pending) {
|
||||
// A first fix older than the retention window is treated as absent: the
|
||||
// timed prune may not have removed it yet, and it must not seed a track.
|
||||
const pendingCutoffSec = Math.floor(
|
||||
(Date.now() - AISSTREAM_STALE_MS) / 1000,
|
||||
);
|
||||
if (!pending || pending.epochSec < pendingCutoffSec) {
|
||||
_aisStreamTrackPending.set(mmsi, { lat, lon, epochSec });
|
||||
return;
|
||||
}
|
||||
@@ -207,6 +230,10 @@ function writeAisTrackSample(track, lat, lon, epochSec) {
|
||||
export function readAisTrack(mmsi) {
|
||||
const track = _aisStreamTracks.get(mmsi);
|
||||
if (!track || !track.len) return [];
|
||||
// An expired vessel stays in the maps until the next timed prune; its track
|
||||
// must read as gone straight away, as its row does in aisStreamRows.
|
||||
const row = _aisStreamVessels.get(mmsi);
|
||||
if (!row || row._updatedAt < Date.now() - AISSTREAM_STALE_MS) return [];
|
||||
const samples = [];
|
||||
const start =
|
||||
(track.head - track.len + AIS_TRACK_SAMPLES) % AIS_TRACK_SAMPLES;
|
||||
@@ -267,14 +294,18 @@ export function aisStreamRows(maxRows) {
|
||||
return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row);
|
||||
}
|
||||
|
||||
function pruneAisStreamCache() {
|
||||
/**
|
||||
* Drop expired vessels and everything kept for them.
|
||||
*
|
||||
* This walks every row, so it runs on a timer (the AIS watchdog tick in
|
||||
* ais-live.js) and not per message. Nothing depends on it for correctness:
|
||||
* readers filter by age and ingest enforces the cap. It only frees memory.
|
||||
*/
|
||||
export function pruneAisStreamCache() {
|
||||
_aisStreamPruneWalks++;
|
||||
const cutoff = Date.now() - AISSTREAM_STALE_MS;
|
||||
for (const [mmsi, row] of _aisStreamVessels) {
|
||||
if (row._updatedAt < cutoff) {
|
||||
_aisStreamVessels.delete(mmsi);
|
||||
_aisStreamTracks.delete(mmsi);
|
||||
_aisStreamTrackPending.delete(mmsi);
|
||||
}
|
||||
if (row._updatedAt < cutoff) dropAisVessel(mmsi);
|
||||
}
|
||||
// Pending single-fix entries for vessels never seen again must not leak
|
||||
const pendingCutoffSec = Math.floor(cutoff / 1000);
|
||||
@@ -282,20 +313,26 @@ function pruneAisStreamCache() {
|
||||
if (pending.epochSec < pendingCutoffSec)
|
||||
_aisStreamTrackPending.delete(mmsi);
|
||||
}
|
||||
if (_aisStreamVessels.size <= AISSTREAM_CACHE_MAX) return;
|
||||
const ordered = [..._aisStreamVessels.entries()].sort(
|
||||
(a, b) => a[1]._updatedAt - b[1]._updatedAt,
|
||||
);
|
||||
for (const [mmsi] of ordered.slice(
|
||||
0,
|
||||
_aisStreamVessels.size - AISSTREAM_CACHE_MAX,
|
||||
)) {
|
||||
_aisStreamVessels.delete(mmsi);
|
||||
_aisStreamTracks.delete(mmsi);
|
||||
_aisStreamTrackPending.delete(mmsi);
|
||||
// Static data that arrived without a position has no vessel row to be
|
||||
// dropped with. It is kept for the position that may follow, but only up to
|
||||
// the vessel cap, oldest first, so the static map is bounded too.
|
||||
for (const mmsi of _aisStreamStatic.keys()) {
|
||||
if (_aisStreamStatic.size <= AISSTREAM_CACHE_MAX) break;
|
||||
if (!_aisStreamVessels.has(mmsi)) _aisStreamStatic.delete(mmsi);
|
||||
}
|
||||
}
|
||||
|
||||
/** Map sizes and the number of prune walks, for tests and diagnostics. */
|
||||
export function aisStreamCacheStats() {
|
||||
return {
|
||||
vessels: _aisStreamVessels.size,
|
||||
statics: _aisStreamStatic.size,
|
||||
tracks: _aisStreamTracks.size,
|
||||
pendingTracks: _aisStreamTrackPending.size,
|
||||
pruneWalks: _aisStreamPruneWalks,
|
||||
};
|
||||
}
|
||||
|
||||
export function newestAisPositionAt(rows) {
|
||||
return rows[0]?.last_position_UTC || null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as store from './ais-store.js';
|
||||
|
||||
const { AISSTREAM_CACHE_MAX, AISSTREAM_STALE_MS } = store;
|
||||
|
||||
// One clock for the whole file, only ever moved forward. The store is module
|
||||
// state shared by every test here, so each test starts by stepping past the
|
||||
// retention window: whatever an earlier test left behind is already expired.
|
||||
let now = Date.parse('2030-01-01T00:00:00Z');
|
||||
const useClock = (t) => {
|
||||
now += AISSTREAM_STALE_MS + 60_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
};
|
||||
|
||||
const position = (mmsi, lat = 53.4, extraMetaData = {}) =>
|
||||
store.ingestAisStreamEnvelope({
|
||||
MessageType: 'PositionReport',
|
||||
Message: { PositionReport: { UserID: Number(mmsi), Sog: 10, Cog: 90 } },
|
||||
MetaData: {
|
||||
MMSI: mmsi,
|
||||
latitude: lat,
|
||||
longitude: 6.2,
|
||||
time_utc: new Date(now).toISOString(),
|
||||
...extraMetaData,
|
||||
},
|
||||
});
|
||||
|
||||
// No position in the header, so this never creates a vessel row by itself.
|
||||
const staticData = (mmsi, body) =>
|
||||
store.ingestAisStreamEnvelope({
|
||||
MessageType: 'ShipStaticData',
|
||||
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
|
||||
MetaData: { MMSI: mmsi },
|
||||
});
|
||||
|
||||
const rows = () => store.aisStreamRows(AISSTREAM_CACHE_MAX);
|
||||
const rowFor = (mmsi) => rows().find((row) => row.mmsi === mmsi);
|
||||
const mmsiAt = (index) => String(300000000 + index);
|
||||
|
||||
/** Fill the store to exactly the cap with vessels 0..cap-1, oldest first. */
|
||||
const fillToCap = () => {
|
||||
for (let index = 0; index < AISSTREAM_CACHE_MAX; index++) {
|
||||
now += 1;
|
||||
position(mmsiAt(index));
|
||||
}
|
||||
};
|
||||
|
||||
test('ingesting messages does not walk the whole cache per message', (t) => {
|
||||
useClock(t);
|
||||
const before = store.aisStreamCacheStats().pruneWalks;
|
||||
for (let index = 0; index < 2000; index++) {
|
||||
now += 1;
|
||||
position(String(210000000 + (index % 200)));
|
||||
}
|
||||
assert.equal(store.aisStreamCacheStats().pruneWalks, before);
|
||||
store.pruneAisStreamCache();
|
||||
assert.equal(store.aisStreamCacheStats().pruneWalks, before + 1);
|
||||
});
|
||||
|
||||
test('readers never see an expired vessel between timed prunes', (t) => {
|
||||
useClock(t);
|
||||
const mmsi = '211000001';
|
||||
position(mmsi, 53.4);
|
||||
now += 60_000;
|
||||
position(mmsi, 53.5);
|
||||
assert.equal(rowFor(mmsi).mmsi, mmsi);
|
||||
assert.equal(store.readAisTrack(mmsi).length, 2);
|
||||
|
||||
// No prune and no other message: the entry is still in the maps.
|
||||
now += AISSTREAM_STALE_MS + 1;
|
||||
assert.equal(rowFor(mmsi), undefined);
|
||||
assert.deepEqual(store.readAisTrack(mmsi), []);
|
||||
});
|
||||
|
||||
test('a first fix older than the retention window never seeds a track', (t) => {
|
||||
useClock(t);
|
||||
const mmsi = '211000002';
|
||||
const old = new Date(now - AISSTREAM_STALE_MS - 60_000).toISOString();
|
||||
position(mmsi, 53.4, { time_utc: old });
|
||||
now += 60_000;
|
||||
position(mmsi, 53.5);
|
||||
assert.deepEqual(store.readAisTrack(mmsi), []);
|
||||
});
|
||||
|
||||
test('past the cap the least recently updated vessels are evicted', (t) => {
|
||||
useClock(t);
|
||||
fillToCap();
|
||||
// Vessel 1 reports again, so it is no longer among the oldest.
|
||||
now += 1;
|
||||
position(mmsiAt(1));
|
||||
for (let index = 0; index < 3; index++) {
|
||||
now += 1;
|
||||
position(mmsiAt(AISSTREAM_CACHE_MAX + index));
|
||||
}
|
||||
|
||||
const kept = new Set(rows().map((row) => row.mmsi));
|
||||
assert.equal(kept.size, AISSTREAM_CACHE_MAX);
|
||||
assert.deepEqual(
|
||||
[0, 1, 2, 3, 4].map((index) => kept.has(mmsiAt(index))),
|
||||
[false, true, false, false, true],
|
||||
);
|
||||
for (let index = 0; index < 3; index++)
|
||||
assert.ok(kept.has(mmsiAt(AISSTREAM_CACHE_MAX + index)));
|
||||
});
|
||||
|
||||
test('an evicted vessel takes its static entry with it', (t) => {
|
||||
useClock(t);
|
||||
const evicted = mmsiAt(0);
|
||||
staticData(evicted, { Type: 70, Destination: 'HAMBURG' });
|
||||
fillToCap();
|
||||
assert.equal(rowFor(evicted).type, '70');
|
||||
now += 1;
|
||||
position(mmsiAt(AISSTREAM_CACHE_MAX));
|
||||
assert.equal(rowFor(evicted), undefined);
|
||||
|
||||
// Were the static entry still cached, the next position would pick it up.
|
||||
now += 1;
|
||||
position(evicted);
|
||||
assert.equal(rowFor(evicted).type, '');
|
||||
assert.equal(rowFor(evicted).destination, '');
|
||||
});
|
||||
|
||||
test('the timed prune drops expired vessels and their static entries', (t) => {
|
||||
useClock(t);
|
||||
store.pruneAisStreamCache();
|
||||
assert.deepEqual(
|
||||
{ ...store.aisStreamCacheStats(), pruneWalks: 0 },
|
||||
{ vessels: 0, statics: 0, tracks: 0, pendingTracks: 0, pruneWalks: 0 },
|
||||
);
|
||||
|
||||
const mmsi = '211000003';
|
||||
// Static data ahead of the first position must still reach the row, and
|
||||
// must survive a prune while it waits.
|
||||
staticData(mmsi, { Type: 70, Destination: 'HAMBURG' });
|
||||
store.pruneAisStreamCache();
|
||||
position(mmsi, 53.4);
|
||||
now += 60_000;
|
||||
position(mmsi, 53.5);
|
||||
assert.equal(rowFor(mmsi).type, '70');
|
||||
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
|
||||
const live = store.aisStreamCacheStats();
|
||||
assert.deepEqual(
|
||||
[live.vessels, live.statics, live.tracks],
|
||||
[1, 1, 1],
|
||||
);
|
||||
|
||||
now += AISSTREAM_STALE_MS + 1;
|
||||
store.pruneAisStreamCache();
|
||||
const pruned = store.aisStreamCacheStats();
|
||||
assert.deepEqual(
|
||||
[pruned.vessels, pruned.statics, pruned.tracks, pruned.pendingTracks],
|
||||
[0, 0, 0, 0],
|
||||
);
|
||||
position(mmsi, 53.6);
|
||||
assert.equal(rowFor(mmsi).type, '');
|
||||
});
|
||||
|
||||
test('static entries that never get a position are capped, oldest first', (t) => {
|
||||
useClock(t);
|
||||
store.pruneAisStreamCache();
|
||||
for (let index = 0; index < AISSTREAM_CACHE_MAX + 5; index++)
|
||||
staticData(mmsiAt(index), { Type: 70 });
|
||||
// A vessel with a row keeps its static entry however old that entry is.
|
||||
position(mmsiAt(0));
|
||||
assert.equal(store.aisStreamCacheStats().statics, AISSTREAM_CACHE_MAX + 5);
|
||||
|
||||
store.pruneAisStreamCache();
|
||||
assert.equal(store.aisStreamCacheStats().statics, AISSTREAM_CACHE_MAX);
|
||||
now += 1;
|
||||
position(mmsiAt(0));
|
||||
position(mmsiAt(3));
|
||||
position(mmsiAt(AISSTREAM_CACHE_MAX));
|
||||
assert.equal(rowFor(mmsiAt(0)).type, '70');
|
||||
assert.equal(rowFor(mmsiAt(3)).type, '');
|
||||
assert.equal(rowFor(mmsiAt(AISSTREAM_CACHE_MAX)).type, '70');
|
||||
});
|
||||
+57
-139
@@ -4,10 +4,10 @@ import {
|
||||
MARKER_ICON_SIZE,
|
||||
SELECTED_MARKER_ICON_SIZE,
|
||||
MARKER_FLOOR_LIFT_M,
|
||||
REMEMBERED_ANCHORS,
|
||||
ANCHOR_SAMPLES_PER_PAINT,
|
||||
SURFACE_BELOW_FLOOR_M,
|
||||
SURFACE_ABOVE_FLOOR_M,
|
||||
SURFACE_SAMPLES_PER_FRAME,
|
||||
SURFACE_SAMPLES_PER_PAINT,
|
||||
} from './policy.js';
|
||||
import {
|
||||
MARKER_IMAGE,
|
||||
@@ -19,17 +19,11 @@ 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,
|
||||
@@ -39,12 +33,7 @@ export function createAlprOverlay({
|
||||
hits = [],
|
||||
images = [],
|
||||
destroyed = false,
|
||||
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();
|
||||
lastCamera = null;
|
||||
const requestPaint = () => {
|
||||
if (!destroyed && state.enabled) lane?.requestPaint();
|
||||
};
|
||||
@@ -103,72 +92,38 @@ export function createAlprOverlay({
|
||||
entity.gevAlprDisplayPosition = null;
|
||||
nativeVisible(entity, true);
|
||||
}
|
||||
surfaceAnswers.clear();
|
||||
state.lastAnchorSampleAt = 0;
|
||||
retryUntil = Date.now() + 15000;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
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) {
|
||||
function anchorFor(record, entity, budget) {
|
||||
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
|
||||
const scene = state.viewer.scene;
|
||||
const location = Cesium.Cartographic.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
);
|
||||
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().
|
||||
// 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).
|
||||
let height;
|
||||
const floor = services.groundFloor.cachedGroundFloor(
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
);
|
||||
if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M;
|
||||
else if (scene.globe.show)
|
||||
height = scene.globe.getHeight?.(
|
||||
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
|
||||
);
|
||||
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);
|
||||
if (!validAlprGroundHeight(height)) return null;
|
||||
setAnchor(record, entity, height);
|
||||
return entity.gevAlprCanvasPosition;
|
||||
@@ -203,19 +158,14 @@ export function createAlprOverlay({
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
function sampleAnchors() {
|
||||
sampling = false;
|
||||
if (destroyed || !state.enabled) return;
|
||||
function refineAnchors(budget) {
|
||||
const camera = state.viewer.camera;
|
||||
const scene = state.viewer.scene;
|
||||
const still =
|
||||
@@ -238,41 +188,27 @@ export function createAlprOverlay({
|
||||
position: Cesium.Cartesian3.clone(camera.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(camera.directionWC),
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (!scene.sampleHeightSupported) return false;
|
||||
let pending = false;
|
||||
for (const record of records) {
|
||||
const entity = state.dataSource.entities.getById(record.id);
|
||||
const anchor = entity?.gevAlprCanvasPosition;
|
||||
if (!anchor || !canSample || entity.gevAlprSurfaceCheckedFor === anchor)
|
||||
continue;
|
||||
if (!still || !surfaceSettled(scene) || samples <= 0) {
|
||||
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
|
||||
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
|
||||
pending = true;
|
||||
continue;
|
||||
}
|
||||
samples -= 1;
|
||||
progressed = true;
|
||||
const sample = surfaceHeight(record, entity);
|
||||
let height = Cesium.Cartographic.fromCartesian(anchor).height;
|
||||
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;
|
||||
const floor = services.groundFloor.cachedGroundFloor(
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
@@ -282,43 +218,21 @@ 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 - height) > 1) {
|
||||
height = sample + MARKER_FLOOR_LIFT_M;
|
||||
setAnchor(record, entity, height);
|
||||
moved = true;
|
||||
}
|
||||
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1)
|
||||
setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M);
|
||||
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;
|
||||
}
|
||||
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);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function paint({ ctx, width, height, keyhole, occluder }) {
|
||||
hits = [];
|
||||
if (!state.enabled || destroyed) return;
|
||||
let waiting = false;
|
||||
let unresolved = false;
|
||||
const painted = [];
|
||||
const canSample =
|
||||
state.viewer.scene.globe?.show ||
|
||||
state.viewer.scene.sampleHeightSupported;
|
||||
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
|
||||
for (const record of records) {
|
||||
const entity = state.dataSource.entities.getById(record.id);
|
||||
if (!entity) continue;
|
||||
@@ -332,14 +246,12 @@ export function createAlprOverlay({
|
||||
nativeVisible(entity, true);
|
||||
continue;
|
||||
}
|
||||
const anchor = anchorFor(record, entity);
|
||||
const anchor = anchorFor(record, entity, budget);
|
||||
if (!anchor) {
|
||||
nativeVisible(entity, true);
|
||||
waiting = true;
|
||||
unresolved = true;
|
||||
continue;
|
||||
}
|
||||
if (canSample && entity.gevAlprSurfaceCheckedFor !== anchor)
|
||||
waiting = true;
|
||||
canvasOwned(entity, true);
|
||||
if (occluder && !occluder.isPointVisible(anchor)) {
|
||||
setShown(entity, false);
|
||||
@@ -423,9 +335,15 @@ export function createAlprOverlay({
|
||||
ctx.restore();
|
||||
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
|
||||
}
|
||||
// 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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -45,10 +45,8 @@ 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: {
|
||||
@@ -108,12 +106,6 @@ 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' } });
|
||||
},
|
||||
@@ -251,149 +243,41 @@ test('overlay glyphs and native badges shrink alike with distance', () => {
|
||||
assert.ok(Math.abs(markerScale(20_250) - 0.8) < 1e-9);
|
||||
});
|
||||
|
||||
/** A photoreal scene: no globe, the rendered surface read by depth samples. */
|
||||
function photoreal(h, heightAt = () => 172) {
|
||||
const scene = h.state.viewer.scene;
|
||||
h.state.viewer.camera.directionWC = Cesium.Cartesian3.UNIT_X;
|
||||
scene.globe.show = false;
|
||||
scene.sampleHeightSupported = true;
|
||||
scene.primitives = { length: 0, get: () => null };
|
||||
const sampled = [];
|
||||
scene.sampleHeight = (carto) => {
|
||||
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));
|
||||
const scene = h.state.viewer.scene;
|
||||
const camera = h.state.viewer.camera;
|
||||
camera.directionWC = Cesium.Cartesian3.UNIT_X;
|
||||
scene.sampleHeightSupported = true;
|
||||
scene.primitives = { length: 0, get: () => null };
|
||||
let samples = 0;
|
||||
scene.sampleHeight = (carto) => {
|
||||
samples++;
|
||||
return Cesium.Math.toDegrees(carto.latitude) > 30.00005 ? 400 : 172;
|
||||
};
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
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');
|
||||
const height = (id) =>
|
||||
Cesium.Cartographic.fromCartesian(
|
||||
h.entities.getById(id).gevAlprCanvasPosition,
|
||||
).height;
|
||||
assert.equal(
|
||||
sampled.length,
|
||||
samples,
|
||||
0,
|
||||
'no depth samples while the camera may still be moving',
|
||||
);
|
||||
settle(t, h);
|
||||
assert.ok(Math.abs(height('camera:0') - 151.5) < 0.01, 'floor anchor first');
|
||||
h.paint();
|
||||
assert.ok(
|
||||
Math.abs(anchorHeight(h, 'camera:0') - 173.5) < 0.01,
|
||||
Math.abs(height('camera:0') - 173.5) < 0.01,
|
||||
'rendered surface once still',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(anchorHeight(h, 'camera:1') - 151.5) < 0.01,
|
||||
Math.abs(height('camera:1') - 151.5) < 0.01,
|
||||
'a rooftop-height sample is rejected',
|
||||
);
|
||||
assert.equal(sampled.length, 2);
|
||||
const taken = samples;
|
||||
h.paint();
|
||||
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');
|
||||
assert.equal(samples, taken, 'each anchor is checked once');
|
||||
});
|
||||
@@ -66,15 +66,14 @@ 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 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;
|
||||
/** Rendered-surface samples one overlay paint may take (each is a depth render). */
|
||||
export const ANCHOR_SAMPLES_PER_PAINT = 4;
|
||||
|
||||
/** 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. */
|
||||
|
||||
Reference in new issue
Block a user