fix(street-level): budgeted mesh sampling, in-place cones, date line, horizon

From the IC8 review of #768, each with tests that fail before:

- P1: a mesh batch no longer rebuilds the selected sequence's cones. The
  drawn cones are indexed by mesh cell; a batch that touches none of them
  is ignored and the affected cones are moved in place (no removeAll/add).
- P2: mesh sampling keeps to its 6 ms slices. Requests are taken in inside
  the budget, only cells within range of the camera are queued, the queue
  is a persistent min-heap re-ranked in one pass only after a 100 m camera
  move, and the deadline starts before any work. Coverage keeps a bounding
  box per line and densifies a line only once it comes within range. On a
  synthetic dense tile (600 x 40 points): slice median ~10 ms -> 5 ms, max
  18-30 ms -> 6.7 ms, refresh cost 11-24 ms -> 1.5-3 ms.
- P2: image cones and the photo marker are horizon-culled like the
  overview dots (one createHorizonCull in view.js, shared by all three).
- P2: a selection change recolours the previous zoom's lines still on
  screen and the old lines a cast swap keeps showing.
- P2: distances (metresBetween), line densification and ground-corner
  requests go the short way round +-180 deg (wrapLon in tileMath.js), so
  nearest ordering, cone thinning, mesh range and cast lines are right
  across the date line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-06 15:16:57 +01:00
1 parent 09fc10756c
commit bd97d38a99
15 files changed
+1085 -164

No files matched your search

+171 -46
View File
@@ -9,7 +9,11 @@ import { metresBetween, whenIdle } from './view.js';
* `sampleHeight` renders a pick pass per probe (about 1–3 ms), so probes run
* in idle time within a small budget, nearest the camera first, and only
* within MESH_SAMPLE_RADIUS_M of the point under the camera: the streamed
* detail is fine there, and an error is visible at all. A probe that hits
* detail is fine there, and an error is visible at all. Requests are taken
* in by the same budgeted slices, cells out of range then are not queued
* (callers ask again when the camera moves), and the queue is a heap ranked
* from where the camera was, re-ranked only once it has moved MESH_RERANK_M,
* so no slice walks or sorts the whole queue. A probe that hits
* nothing (tiles not streamed yet) is retried later rather than latched.
* Overlays are excluded from the probe, so only 3D tilesets are hit.
*/
@@ -18,8 +22,10 @@ import { metresBetween, whenIdle } from './view.js';
export const MESH_CELL_DEG = 0.0001;
/** Cells farther than this from the point under the camera are not probed. */
export const MESH_SAMPLE_RADIUS_M = 900;
/** Main-thread time spent probing per idle slice. */
/** Main-thread time per idle slice, taking requests in and ranking included. */
export const MESH_SAMPLE_BUDGET_MS = 6;
/** The queue is re-ranked once the camera has moved this far, in metres. */
export const MESH_RERANK_M = 100;
/** A cell whose probe missed is tried again after this long. */
export const MESH_MISS_RETRY_MS = 8000;
/** Cached cells (and remembered misses) before they are dropped and refilled. */
@@ -28,7 +34,52 @@ const MESH_CACHE_MAX = 80_000;
const MESH_NOTIFY_MS = 700;
const cellOf = (value) => Math.round(value / MESH_CELL_DEG);
const keyOf = (lon, lat) => `${cellOf(lon)},${cellOf(lat)}`;
/**
* The cache key of the mesh cell holding a point, one per ~11 m cell: a
* number (exact well within 2^53), as a string key would be built per point.
*/
export const meshCellKey = (lon, lat) =>
(cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000;
/** Add a queued cell to a min-heap on `distance`. */
function heapPush(heap, cell) {
let i = heap.push(cell) - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (heap[parent].distance <= cell.distance) break;
heap[i] = heap[parent];
i = parent;
}
heap[i] = cell;
}
/** Restore the heap below `i` after its cell was replaced. */
function siftDown(heap, i) {
const cell = heap[i];
const n = heap.length;
for (;;) {
let child = 2 * i + 1;
if (child >= n) break;
if (child + 1 < n && heap[child + 1].distance < heap[child].distance)
child++;
if (heap[child].distance >= cell.distance) break;
heap[i] = heap[child];
i = child;
}
heap[i] = cell;
}
/** Take the nearest cell off a min-heap. */
function heapPop(heap) {
const top = heap[0];
const last = heap.pop();
if (heap.length) {
heap[0] = last;
siftDown(heap, 0);
}
return top;
}
/** Every top-level primitive that is not a 3D tileset: what a probe skips. */
function overlays(scene) {
@@ -42,13 +93,14 @@ function overlays(scene) {
}
/**
* @param {{getViewer: () => object|null, budgetMs?: number, radiusM?: number, cacheMax?: number}} options
* @param {{getViewer: () => object|null, budgetMs?: number, radiusM?: number, cacheMax?: number, now?: () => number}} options
*/
export function createMeshSampler({
getViewer,
budgetMs = MESH_SAMPLE_BUDGET_MS,
radiusM = MESH_SAMPLE_RADIUS_M,
cacheMax = MESH_CACHE_MAX,
now = () => performance.now(),
}) {
/** cell key → sampled mesh height (ellipsoidal metres). */
const heights = new Map();
@@ -58,8 +110,16 @@ export function createMeshSampler({
* height cache, so the map stays bounded.
*/
const misses = new Map();
/** cell key → {lon, lat} cell centre waiting to be probed. */
/** cell key → queued cell, so a cell is queued once. */
const wanted = new Map();
/** Queued {key, lon, lat, distance} cells, a min-heap on distance from `rankedFrom`. */
let queue = [];
/** Requests not yet taken in: {points, next, centre} (centre: the camera then). */
let intake = [];
/** Where the camera was when the queue was ranked. */
let rankedFrom = null;
/** Running estimate of one probe's cost, so a slice stops before overrunning. */
let probeMs = 1;
const listeners = new Set();
let enabled = false;
let running = false;
@@ -68,32 +128,90 @@ export function createMeshSampler({
/** The sampled mesh height for a point, or undefined when not sampled. */
function meshAt(lon, lat) {
return heights.get(keyOf(lon, lat));
return heights.get(meshCellKey(lon, lat));
}
/** Queue the cells under these [lon, lat] points for probing. */
/** The point under the camera, or null. */
function cameraCentre() {
const carto = getViewer()?.camera?.positionCartographic;
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
};
}
/**
* Ask for the cells under these [lon, lat] points (read later, so do not
* change the list). Nothing is done here: idle slices take requests in
* within their budget, and drop cells out of range of where the camera was
* when asked; callers ask again once it moves.
*/
function request(points) {
if (!enabled) return;
const now = Date.now();
for (const [lon, lat] of points) {
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const key = keyOf(lon, lat);
if (heights.has(key) || wanted.has(key)) continue;
const retryAt = misses.get(key);
if (retryAt !== undefined) {
if (retryAt > now) continue;
misses.delete(key);
}
wanted.set(key, {
lon: cellOf(lon) * MESH_CELL_DEG,
lat: cellOf(lat) * MESH_CELL_DEG,
});
}
if (!enabled || !points?.length) return;
const centre = cameraCentre();
if (!centre) return;
intake.push({ points, next: 0, centre });
schedule();
}
/** Queue requested cells, oldest request first, until the slice reaches `until`. */
function takeIn(until) {
const time = Date.now();
while (intake.length) {
const pending = intake[0];
const { points, centre } = pending;
while (pending.next < points.length) {
if ((pending.next & 255) === 255 && now() > until) return;
const [lon, lat] = points[pending.next++];
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const key = meshCellKey(lon, lat);
if (heights.has(key) || wanted.has(key)) continue;
const cell = {
key,
lon: cellOf(lon) * MESH_CELL_DEG,
lat: cellOf(lat) * MESH_CELL_DEG,
distance: 0,
};
if (metresBetween(cell, centre) > radiusM) continue;
const retryAt = misses.get(key);
if (retryAt !== undefined) {
if (retryAt > time) continue;
misses.delete(key);
}
cell.distance = metresBetween(cell, rankedFrom);
wanted.set(key, cell);
heapPush(queue, cell);
}
intake.shift();
}
}
/**
* Rank the queue from where the camera is now, dropping cells it has left
* out of range: one linear heapify, run only after the camera has moved.
*/
function rerank(centre) {
rankedFrom = centre;
const kept = [];
for (const cell of queue) {
cell.distance = metresBetween(cell, centre);
if (cell.distance > radiusM) wanted.delete(cell.key);
else kept.push(cell);
}
queue = kept;
for (let i = (queue.length >> 1) - 1; i >= 0; i--) siftDown(queue, i);
}
function clearQueue() {
intake = [];
wanted.clear();
queue = [];
rankedFrom = null;
}
function schedule() {
if (running || !enabled || !wanted.size) return;
if (running || !enabled || !(queue.length || intake.length)) return;
running = true;
whenIdle(step, 300);
}
@@ -113,32 +231,38 @@ export function createMeshSampler({
function step(deadline) {
running = false;
// The slice starts now: taking requests in and ranking count too.
const started = now();
const until =
started +
Math.min(budgetMs, Math.max(1, deadline?.timeRemaining?.() ?? budgetMs));
const scene = getViewer()?.scene;
const carto = getViewer()?.camera?.positionCartographic;
if (!enabled || !scene?.sampleHeightSupported || !carto) return;
const centre = {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
};
// Nearest first; cells out of range are dropped (they stay bare earth).
const order = [];
for (const [key, point] of wanted) {
const distance = metresBetween(point, centre);
if (distance > radiusM) wanted.delete(key);
else order.push([distance, key, point]);
const centre = cameraCentre();
if (!enabled || !scene?.sampleHeightSupported || !centre) return;
// Nearest first, ranked from where the camera was; out-of-range cells
// are dropped (they stay bare earth).
let worked = false;
if (!rankedFrom || metresBetween(rankedFrom, centre) > MESH_RERANK_M) {
rerank(centre);
worked = true;
}
if (intake.length) {
// Leave room for a probe, so a stream of requests cannot starve them.
takeIn(until - probeMs);
worked = true;
}
order.sort((a, b) => a[0] - b[0]);
const slice = Math.max(
1,
Math.min(budgetMs * 2, deadline?.timeRemaining?.() ?? budgetMs),
);
const until = performance.now() + Math.min(budgetMs, slice);
const exclude = overlays(scene);
let probed = 0;
for (const [, key, { lon, lat }] of order) {
if (probed && performance.now() > until) break;
while (queue.length) {
// Stop before a probe would overrun the slice. A slice that did
// nothing else always probes once, so the queue drains.
if ((probed || worked) && now() + probeMs > until) break;
const { key, lon, lat } = heapPop(queue);
wanted.delete(key);
// Ranked from up to MESH_RERANK_M away: check the range from here.
if (metresBetween({ lon, lat }, centre) > radiusM) continue;
probed++;
const probeStarted = now();
let height;
try {
height = scene.sampleHeight(
@@ -148,6 +272,7 @@ export function createMeshSampler({
} catch {
height = undefined;
}
probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25;
if (!Number.isFinite(height)) {
if (misses.size >= cacheMax) misses.clear();
misses.set(key, Date.now() + MESH_MISS_RETRY_MS);
@@ -172,7 +297,7 @@ export function createMeshSampler({
/** Probe only while overlays are cast (Google 3D at street zoom). */
function setEnabled(on) {
enabled = on === true;
if (!enabled) wanted.clear();
if (!enabled) clearQueue();
else schedule();
}
@@ -185,7 +310,7 @@ export function createMeshSampler({
function destroy() {
enabled = false;
forget();
wanted.clear();
clearQueue();
listeners.clear();
clearTimeout(notifyTimer);
notifyTimer = null;