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

+10 -3
View File
@@ -14,6 +14,8 @@
* and the caller keeps its draped placement.
*/
import { wrapLon } from './tileMath.js';
/** Grid spacing in degrees (~111 m north-south): the ground-floor grid. */
export const GROUND_CAST_STEP_DEG = 0.001;
/** Longest line segment left undivided, in degrees, so lines follow the grid. */
@@ -107,6 +109,8 @@ export function nextSurfaceMode(current, { photoreal, heightM, available }) {
/**
* Split long segments so a line follows the terrain between its vertices.
* A segment across ±180° is split the short way round, and the points it
* adds are wrapped back into [-180, 180].
* @param {Array<[number, number]>} coords [lon, lat] pairs
* @param {number} [maxStep] longest segment, in degrees
* @returns {Array<[number, number]>}
@@ -117,11 +121,12 @@ export function densifyLine(coords, maxStep = GROUND_CAST_DENSIFY_DEG) {
for (let i = 1; i < coords.length; i++) {
const [lon0, lat0] = coords[i - 1];
const [lon1, lat1] = coords[i];
const span = Math.max(Math.abs(lon1 - lon0), Math.abs(lat1 - lat0));
const dLon = wrapLon(lon1 - lon0);
const span = Math.max(Math.abs(dLon), Math.abs(lat1 - lat0));
const parts = Math.min(64, Math.ceil(span / maxStep - 1e-9));
for (let k = 1; k < parts; k++) {
const t = k / parts;
out.push([lon0 + (lon1 - lon0) * t, lat0 + (lat1 - lat0) * t]);
out.push([wrapLon(lon0 + dLon * t), lat0 + (lat1 - lat0) * t]);
}
out.push(coords[i]);
}
@@ -177,9 +182,11 @@ export function createGroundCaster({
const missing = missingCorners(points);
if (!missing.length) return true;
if (missing.length > maxCorners) return false;
// A corner east of 180° (the far corner of a point on the meridian) is
// asked for at its longitude on the other side.
const results = await terrain.resolveEllipsoidalGround(
missing.map(([, { i, j }]) => ({
lon: Number((i * step).toFixed(6)),
lon: Number(wrapLon(i * step).toFixed(6)),
lat: Number((j * step).toFixed(6)),
})),
{ signal },
@@ -83,6 +83,32 @@ test('densifyLine splits long segments and keeps the vertices', () => {
assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]);
});
test('densifyLine crosses the date line the short way and wraps what it adds (review IC8 P2)', () => {
// 0.0002° apart: short enough to stay one segment, not 65 points via 0°.
const short = [
[179.9999, 0],
[-179.9999, 0],
];
assert.deepEqual(densifyLine(short), short);
const line = [
[179.999, 50.0005],
[-179.999, 50.0005],
];
const dense = densifyLine(line, 0.0005);
assert.equal(dense.length, 5);
assert.deepEqual(dense[0], line[0]);
assert.deepEqual(dense.at(-1), line[1]);
for (const [lon] of dense)
assert.ok(lon >= -180 && lon <= 180 && Math.abs(lon) >= 179.999, `${lon}`);
assert.ok(Math.abs(dense[1][0] - 179.9995) < 1e-9);
assert.ok(Math.abs(Math.abs(dense[2][0]) - 180) < 1e-9);
assert.ok(Math.abs(dense[3][0] - -179.9995) < 1e-9);
// Westward across the date line too.
const back = densifyLine([...line].reverse(), 0.0005);
assert.equal(back.length, 5);
assert.ok(Math.abs(back[1][0] - -179.9995) < 1e-9);
});
test('the caster interpolates cached grid corners and adds the lift', async () => {
const terrain = fakeTerrain();
const caster = createGroundCaster({ terrain, step: 0.001 });
@@ -305,3 +331,41 @@ test('prepared lines always cast with the mesh, however their segments cross the
}
assert.equal(failures, 0);
});
test('a line across the date line casts along the date line (review IC8 P2)', async () => {
// Ground that rises northward only, so both sides of ±180° agree.
const calls = [];
const terrain = {
async resolveEllipsoidalGround(coords) {
calls.push(...coords);
return coords.map(({ lat }) => ({
ellipsoid: 100 + (lat - 50) * 2000,
source: 'reearth',
}));
},
};
const caster = createGroundCaster({ terrain, step: 0.001 });
const line = [
[179.9995, 50.0005],
[180, 50.0005],
[-179.9995, 50.0005],
];
assert.equal(await caster.prepareLines([line]), true);
for (const meshAt of [null, () => undefined]) {
const flat = caster.castLine(line, { meshAt });
assert.ok(flat, 'every point casts');
assert.ok(
flat.length / 3 <= 8,
`${flat.length / 3} points, not a line round the world`,
);
for (let i = 0; i < flat.length; i += 3) {
assert.ok(Math.abs(flat[i]) >= 179.999 && Math.abs(flat[i]) <= 180);
assert.ok(Math.abs(flat[i + 2] - (101 + GROUND_CAST_LIFT_M)) < 1e-6);
}
}
// Only ground near the date line is asked for, and every longitude is a
// real one (the far corner of a point on 180° is asked for at -179.999°).
assert.ok(calls.length <= 16, `${calls.length} corners`);
for (const { lon } of calls)
assert.ok(Math.abs(lon) >= 179.999 && Math.abs(lon) <= 180, `${lon}`);
});
+11
View File
@@ -2,6 +2,7 @@ import * as Cesium from 'cesium';
import { positionMarkerGlyph } from './glyphs.js';
import { COLORS, POSITION_PICK_ID } from './policy.js';
import { refineHeights } from './groundCast.js';
import { createHorizonCull } from './view.js';
const SPRITE_ID = 'street-level:marker';
@@ -18,6 +19,13 @@ export function createMarker({ state, parts }) {
render?.governorRequestRender?.('street-level-marker');
}
// It skips the depth test: hide it while it is behind the globe.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: () => (state.marker.billboard ? [state.marker.billboard] : []),
onChange: requestRender,
});
function ensure(viewer) {
if (state.marker.collection) return;
state.marker.collection = new Cesium.BillboardCollection({
@@ -61,6 +69,7 @@ export function createMarker({ state, parts }) {
if (!position) {
collection.removeAll();
state.marker.billboard = null;
horizon.stop();
requestRender();
return;
}
@@ -92,6 +101,7 @@ export function createMarker({ state, parts }) {
state.marker.billboard.position = cartesian;
}
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
horizon.update([state.marker.billboard]);
requestRender();
}
@@ -101,6 +111,7 @@ export function createMarker({ state, parts }) {
}
function destroy(viewer) {
horizon.stop();
const collection = state.marker.collection;
if (!collection) return;
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
+78
View File
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createMarker } from './marker.js';
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
const noop = () => {};
const context2d = new Proxy({}, { get: () => noop, set: () => true });
globalThis.document ??= {
createElement: () => ({ getContext: () => context2d }),
};
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
function setup() {
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
const preRender = new Set();
const viewer = {
camera: {
get positionWC() {
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
},
},
scene: {
primitives: { add: (p) => p, remove() {} },
preRender: {
addEventListener(listener) {
preRender.add(listener);
return () => preRender.delete(listener);
},
},
},
};
const state = {
services: {},
viewer,
surface: 'draped',
marker: {
// Stands in for the billboard collection; ensure() keeps it.
collection: {
show: true,
add: (options) => ({ ...options }),
removeAll() {},
},
billboard: null,
},
};
const marker = createMarker({ state, parts: {} });
const frame = () => {
for (const listener of [...preRender]) listener();
};
return { state, view, preRender, marker, frame };
}
test('the photo marker is hidden behind the globe, and shows from its side (review IC8 P2)', () => {
const { state, view, preRender, marker, frame } = setup();
// Placed on the far side of the Earth from the camera.
marker.set({ lon: 58.51, lat: -38.58 }, 90);
const billboard = state.marker.billboard;
assert.equal(billboard.show, false, 'not drawn through the globe');
// Fly round to its side: it shows.
view.lon = 58.51;
view.lat = -38.58;
frame();
assert.equal(billboard.show, true);
// Moved to the far side again, without the camera moving.
marker.set({ lon: -121.49, lat: 38.58 }, 0);
assert.equal(billboard.show, false, 'culled where it now stands');
marker.clear();
assert.equal(preRender.size, 0, 'the cull listener is gone');
});
test('destroying the marker stops the horizon cull', () => {
const { marker, preRender } = setup();
marker.set({ lon: -121.49, lat: 38.58 }, 0);
assert.equal(preRender.size, 1);
marker.destroy();
assert.equal(preRender.size, 0);
});
+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;
+131
View File
@@ -4,8 +4,10 @@ import * as Cesium from 'cesium';
import {
MESH_CELL_DEG,
MESH_MISS_RETRY_MS,
MESH_SAMPLE_BUDGET_MS,
createMeshSampler,
} from './meshSampler.js';
import { metresBetween } from './view.js';
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
const RAD = Math.PI / 180;
@@ -136,3 +138,132 @@ test('destroy forgets the samples', (t) => {
sampler.destroy();
assert.equal(sampler.meshAt(10.001, 50), undefined);
});
/**
* A sampler over `fakeViewer` on a fake clock: each probe costs `probeMs`
* and each read of the camera `cameraMs` (work done before any probe).
*/
function clocked(t, { probeMs = 2, cameraMs = 0, holes } = {}) {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const fake = fakeViewer({ holes });
const clock = { now: 0 };
const { scene, camera } = fake.viewer;
const probe = scene.sampleHeight;
scene.sampleHeight = (...args) => {
clock.now += probeMs;
return probe(...args);
};
const position = { ...camera.positionCartographic };
Object.defineProperty(camera, 'positionCartographic', {
get: () => {
clock.now += cameraMs;
return position;
},
});
const sampler = createMeshSampler({
getViewer: () => fake.viewer,
now: () => clock.now,
});
sampler.setEnabled(true);
/** Run one idle slice; its length on the fake clock. */
function slice() {
const started = clock.now;
probeNow(t);
return clock.now - started;
}
/** Put the camera over a point. */
function moveTo(lon, lat) {
position.longitude = lon * RAD;
position.latitude = lat * RAD;
}
return { ...fake, sampler, slice, moveTo };
}
/** Points on a grid `step` degrees apart, `n`×`n`, centred on a point. */
function grid(lon, lat, n, step) {
const points = [];
for (let i = 0; i < n; i++)
for (let j = 0; j < n; j++)
points.push([
lon + (i - (n - 1) / 2) * step,
lat + (j - (n - 1) / 2) * step,
]);
return points;
}
test('cells out of range when asked are not queued, even once the camera reaches them (review IC8 P2)', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
// 400 cells ~3.6 km east, past the sampler's range.
sampler.request(grid(10.05, 50, 20, 0.0002));
// The camera gets there before any idle slice runs.
moveTo(10.05, 50);
for (let i = 0; i < 5; i++) slice();
assert.equal(probes.length, 0, 'nothing was queued for later');
// Asked again from there (as a camera move does), they are probed.
sampler.request([[10.05, 50]]);
slice();
assert.equal(probes.length, 1);
sampler.destroy();
});
test('a slice keeps to its budget, counting the work before its first probe (review IC8 P2)', (t) => {
const { sampler, probes, slice } = clocked(t, { probeMs: 2, cameraMs: 3 });
sampler.request(grid(10.001, 50, 5, 0.0002));
const lengths = [];
while (probes.length < 25 && lengths.length < 40) lengths.push(slice());
assert.equal(probes.length, 25, 'every cell is probed in the end');
for (const length of lengths)
assert.ok(length <= MESH_SAMPLE_BUDGET_MS, `a ${length} ms slice`);
sampler.destroy();
});
test('a large queue is ranked once, not sorted again every slice (review IC8 P2)', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
// 2,500 cells ~17 m apart around the camera, all within range.
const cells = grid(10, 50, 50, 0.00015);
sampler.request(cells);
const sort = Array.prototype.sort;
let sorted = 0;
t.after(() => (Array.prototype.sort = sort));
Array.prototype.sort = function (...args) {
if (this.length >= 100) sorted += this.length;
return sort.apply(this, args);
};
for (let i = 0; i < 10; i++) slice();
const first = probes.map((probe) =>
metresBetween(probe, { lon: 10, lat: 50 }),
);
assert.ok(first.length >= 10, 'probes ran');
first.forEach((distance, i) =>
assert.ok(
!i || distance >= first[i - 1] - 0.01,
'nearest the camera first, across slices',
),
);
Array.prototype.sort = sort;
assert.equal(sorted, 0, `${sorted} queued cells sorted in 10 slices`);
// The camera moves ~200 m east: the queue is ranked from there.
const done = probes.length;
moveTo(10.003, 50);
slice();
slice();
const after = probes
.slice(done)
.map((probe) => metresBetween(probe, { lon: 10.003, lat: 50 }));
assert.ok(after.length > 0);
assert.ok(after[0] < 20, `nearest the new view first (${after[0]} m)`);
sampler.destroy();
});
test('the sampling range is measured the short way round the date line (review IC8 P2)', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
moveTo(179.9999, 0);
sampler.request([
[-179.9999, 0], // ~22 m away, across the date line
[179.99, 0], // ~1.1 km away, past the range
]);
for (let i = 0; i < 5; i++) slice();
assert.equal(probes.length, 1, 'only the neighbour across ±180°');
assert.ok(Math.abs(Math.abs(probes[0].lon) - 179.9999) < MESH_CELL_DEG);
sampler.destroy();
});
@@ -2,6 +2,7 @@ import * as Cesium from 'cesium';
import { decodeCoverageTile } from './decode.js';
import { passesImageryFilter } from '../../filter.js';
import {
createHorizonCull,
groundUnderCamera,
metresBetween,
viewFocus,
@@ -9,7 +10,7 @@ import {
whenIdle,
} from '../../view.js';
import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js';
import { MESH_CELL_DEG } from '../../meshSampler.js';
import { MESH_SAMPLE_RADIUS_M } from '../../meshSampler.js';
import {
coverageZoomForHeight,
overviewZoomForHeight,
@@ -256,27 +257,67 @@ export function createCoverage({ state, source }) {
removeWhenReady(entry, previous);
}
/** The mesh cells under a tile's drawn lines, built once per filter. */
function meshCells(entry) {
if (entry.meshCells) return entry.meshCells;
const cells = new Map();
for (const sequence of entry.sequences.values())
for (const [lon, lat] of sequence.parts.flatMap((part) =>
densifyLine(part, MESH_DENSIFY_DEG),
))
cells.set(
`${Math.round(lon / MESH_CELL_DEG)},${Math.round(lat / MESH_CELL_DEG)}`,
[lon, lat],
);
entry.meshCells = [...cells.values()];
return entry.meshCells;
/** The point under the camera: where the mesh sampler measures its range from. */
function meshCentre() {
const carto = state.viewer?.camera?.positionCartographic;
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
};
}
/** Ask for mesh samples under a cast tile's lines (the sampler keeps the near ones). */
/** Whether any of a {west, south, east, north} box is within the sampler's range. */
function inMeshRange(box, centre) {
const nearest = {
lon: Math.min(box.east, Math.max(box.west, centre.lon)),
lat: Math.min(box.north, Math.max(box.south, centre.lat)),
};
return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M;
}
/**
* A tile's drawn lines with their bounding boxes, built once per filter;
* each line's mesh points are densified the first time it is in range.
*/
function meshParts(entry) {
if (entry.meshParts) return entry.meshParts;
entry.meshParts = [];
for (const sequence of entry.sequences.values())
for (const part of sequence.parts) {
const box = {
west: Infinity,
south: Infinity,
east: -Infinity,
north: -Infinity,
};
for (const [lon, lat] of part) {
if (lon < box.west) box.west = lon;
if (lon > box.east) box.east = lon;
if (lat < box.south) box.south = lat;
if (lat > box.north) box.north = lat;
}
entry.meshParts.push({ part, box, points: null });
}
return entry.meshParts;
}
/**
* Ask for mesh samples under a cast tile's lines within the sampler's range
* of the camera; the rest are asked for when a camera move brings them in.
*/
function requestMesh(entry) {
const sampler = state.context.meshSampler;
if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return;
sampler.request(meshCells(entry));
const centre = meshCentre();
if (!centre || !entry.bounds || !inMeshRange(entry.bounds, centre)) return;
const points = [];
for (const line of meshParts(entry)) {
if (!inMeshRange(line.box, centre)) continue;
line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG);
for (const point of line.points) points.push(point);
}
if (points.length) sampler.request(points);
}
/**
@@ -382,7 +423,7 @@ export function createCoverage({ state, source }) {
color: green,
pixelSize: COVERAGE_OVERVIEW_POINT_PX,
// Google 3D terrain and clouds must not hide the near side's dots;
// `cullHorizon` hides the far side's, which this lets through.
// the horizon cull hides the far side's, which this lets through.
disableDepthTestDistance: Number.POSITIVE_INFINITY,
}),
);
@@ -390,53 +431,18 @@ export function createCoverage({ state, source }) {
return { collection, points: drawn };
}
/**
* Hide the overview points behind the horizon, as the cyclones layer does.
* They skip the depth test, so nothing else stops the far hemisphere's
* coverage drawing over this one at whole-earth zooms; a finite skip
* distance cannot do it, as the horizon's distance moves with the camera.
*/
function cullHorizon() {
const position = state.viewer?.camera?.positionWC;
if (!position) return;
const entries = [
...state.coverage.tiles.values(),
...state.coverage.stale.values(),
].filter((entry) => entry.overviewPoints);
if (!entries.length) {
stopHorizonCull();
return;
}
const from = state.coverage.cullFrom;
if (from && Cesium.Cartesian3.equals(from, position)) return;
state.coverage.cullFrom = Cesium.Cartesian3.clone(position, from);
let changed = false;
for (const entry of entries)
if (cullPoints(entry.overviewPoints, position)) changed = true;
if (changed) requestRender();
/** Every overview point on the globe, current zoom and old alike. */
function* overviewPoints() {
for (const entry of drawnEntries())
if (entry.overviewPoints) yield* entry.overviewPoints;
}
/** Show the points in front of the horizon seen from `position`, hide the rest. */
function cullPoints(points, position) {
const occluder = (state.coverage.occluder ||=
new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84));
occluder.cameraPosition = position;
let changed = false;
for (const point of points) {
const show = occluder.isPointVisible(point.position);
if (point.show !== show) {
point.show = show;
changed = true;
}
}
return changed;
}
function stopHorizonCull() {
state.coverage.stopHorizonCull?.();
state.coverage.stopHorizonCull = null;
state.coverage.cullFrom = null;
}
// Overview points skip the depth test: hide the far hemisphere's.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: overviewPoints,
onChange: requestRender,
});
function attachPrimitive(entry) {
const scene = state.viewer?.scene;
@@ -463,11 +469,8 @@ export function createCoverage({ state, source }) {
entry.overviewPoints = points;
entry.count = points.length;
scene.primitives.add(collection);
// Cull the new points now, then every frame the camera has moved.
const camera = state.viewer.camera?.positionWC;
if (camera) cullPoints(points, camera);
state.coverage.stopHorizonCull ||=
scene.preRender?.addEventListener(cullHorizon) || null;
// Cull the new points now, then all of them whenever the camera moves.
horizon.update(points);
}
}
@@ -777,7 +780,7 @@ export function createCoverage({ state, source }) {
state.coverage.remeshTimer = null;
state.coverage.remeshDirty.clear();
stopSelectionWatch();
stopHorizonCull();
horizon.stop();
requestRender();
}
@@ -787,7 +790,7 @@ export function createCoverage({ state, source }) {
for (const entry of state.coverage.tiles.values()) {
cancelCast(entry);
entry.castRequested = false;
entry.meshCells = null;
entry.meshParts = null;
detachPrimitive(entry);
attachPrimitive(entry);
}
@@ -823,12 +826,26 @@ export function createCoverage({ state, source }) {
}
}
/**
* Every tile drawn on the globe: the current zoom's, and the old zoom's
* kept visible until the new one is in (see retire).
*/
function drawnEntries() {
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
}
/** A tile's primitives on the globe: its own, and the ones a swap still shows. */
function drawnRecords(entry) {
const records = entry.primitives || [];
return entry.swap ? [...records, ...entry.swap.old] : records;
}
/** Recolour one sequence, every part of it, in place (selection highlight). */
function recolorSequence(id, selected) {
for (const entry of state.coverage.tiles.values()) {
for (const entry of drawnEntries()) {
const sequence = entry.sequences.get(id);
if (!sequence) continue;
for (const record of entry.primitives || []) {
for (const record of drawnRecords(entry)) {
// Still building: `syncSelection` catches it up once it is ready.
if (!record.primitive.ready) continue;
recolorInstances(record.primitive, sequence, selected);
@@ -867,11 +884,15 @@ export function createCoverage({ state, source }) {
const stop = scene.postRender.addEventListener(() => {
let building = false;
let changed = false;
for (const entry of state.coverage.tiles.values())
for (const entry of drawnEntries()) {
for (const record of entry.primitives || []) {
if (!record.primitive.ready) building = true;
else if (syncSelection(entry, record)) changed = true;
}
// Lines a swap still shows were ready (or never show): catch up only.
for (const record of entry.swap?.old || [])
if (syncSelection(entry, record)) changed = true;
}
if (changed) requestRender();
if (!building) stopSelectionWatch();
});
@@ -512,49 +512,52 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i
coverage.clear();
});
/** The colour attribute value of a line, selected or not. */
function colourValue(selected) {
return Array.from(
Cesium.ColorGeometryInstanceAttribute.toValue(
selected
? Cesium.Color.fromCssColorString(COLORS.selected)
: Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92),
),
);
}
/** Take over a drawn primitive's readiness and seq-1's colour attribute. */
function controlled(record, ready) {
const control = { ready, attributes: { color: undefined } };
Object.defineProperty(record.primitive, 'ready', {
get: () => control.ready,
});
record.primitive.getGeometryInstanceAttributes = (id) =>
id === 'mly:seq:seq-1' ? control.attributes : undefined;
return control;
}
test('a selection made or cleared while a tile builds is applied once it is ready', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const [entry] = state.coverage.tiles.values();
const value = (selected) =>
Array.from(
Cesium.ColorGeometryInstanceAttribute.toValue(
selected
? Cesium.Color.fromCssColorString(COLORS.selected)
: Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92),
),
);
/** Stand in for a primitive the worker has not finished, with its colours. */
function building(record) {
const control = { ready: false, attributes: { color: undefined } };
Object.defineProperty(record.primitive, 'ready', {
get: () => control.ready,
});
record.primitive.getGeometryInstanceAttributes = (id) =>
id === 'mly:seq:seq-1' ? control.attributes : undefined;
return control;
}
// Selected while its line is still building: nothing to recolour yet...
const first = building(entry.primitives[0]);
const first = controlled(entry.primitives[0], false);
state.sequence.selectedId = 'seq-1';
coverage.recolorSequence('seq-1', true);
assert.equal(first.attributes.color, undefined);
// ...so the highlight lands once it is ready.
first.ready = true;
frame(viewer.postRender);
assert.deepEqual(Array.from(first.attributes.color), value(true));
assert.deepEqual(Array.from(first.attributes.color), colourValue(true));
// Rebuilt with the highlight baked in, then cleared mid-build: not stuck.
coverage.rebuild();
const second = building(entry.primitives[0]);
const second = controlled(entry.primitives[0], false);
coverage.recolorSequence('seq-1', false);
state.sequence.selectedId = null;
second.ready = true;
frame(viewer.postRender);
assert.deepEqual(Array.from(second.attributes.color), value(false));
assert.deepEqual(Array.from(second.attributes.color), colourValue(false));
coverage.clear();
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
});
@@ -727,3 +730,112 @@ test('a street view toward the horizon ranks only the ground within range', () =
assert.ok(Math.abs(dx) <= 1 && Math.abs(dy) <= 1, `${dx},${dy} around it`);
coverage.clear();
});
test('a selection cleared while the old zoom is still shown uncolours its lines (review IC8 P2)', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const [entry] = state.coverage.tiles.values();
const line = controlled(entry.primitives[0], true);
state.sequence.selectedId = 'seq-1';
coverage.recolorSequence('seq-1', true);
assert.deepEqual(Array.from(line.attributes.color), colourValue(true));
// Zoom out: the street tile stays on screen while the new zoom loads...
viewer.view.height = 100_000;
coverage.refresh();
assert.equal(state.coverage.stale.size, 1, 'the old zoom is kept');
// ...and the selection is cleared meanwhile.
state.sequence.selectedId = null;
coverage.recolorSequence('seq-1', false);
assert.deepEqual(
Array.from(line.attributes.color),
colourValue(false),
'the retained line is not left cyan',
);
coverage.clear();
});
test('a selection made while the old zoom is still building is applied once it is ready (review IC8 P2)', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const [entry] = state.coverage.tiles.values();
const line = controlled(entry.primitives[0], false);
viewer.view.height = 100_000;
coverage.refresh();
assert.equal(state.coverage.stale.size, 1);
frame(viewer.postRender);
state.sequence.selectedId = 'seq-1';
coverage.recolorSequence('seq-1', true);
line.ready = true;
frame(viewer.postRender);
assert.deepEqual(
line.attributes.color && Array.from(line.attributes.color),
colourValue(true),
'the highlight lands on the retained line',
);
coverage.clear();
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
});
test('only lines within the mesh sampler range of the camera are asked for (review IC8 P2)', async () => {
let heightsReady = false;
const groundCaster = {
prepareLines: async () => (heightsReady = true),
castLine: (coords) =>
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
};
const requested = [];
const meshSampler = {
onSampled() {},
request: (points) => requested.push(...points),
meshAt: () => undefined,
};
const { source, coverage, centre } = setup({
surface: 'terrain',
groundCaster,
meshSampler,
});
const { east, north } = tileBounds(centre.tile.x, centre.tile.y, 14);
coverage.refresh();
source.calls[0].resolve(
encodeCoverageTile(centre.tile, {
sequences: [
{
id: 'here',
parts: [
[
[centre.lon - 0.001, centre.lat],
[centre.lon + 0.001, centre.lat],
],
],
},
// ~1.1 km from the camera, in the tile's north-east corner.
{
id: 'corner',
parts: [
[
[east - 0.002, north - 0.002],
[east - 0.0005, north - 0.0005],
],
],
},
],
}),
);
await settle();
await settle();
assert.ok(requested.length > 0, 'the near line is asked for');
const farthest = Math.max(
...requested.map(([lon, lat]) =>
Math.hypot(
(lon - centre.lon) * 111_320 * Math.cos(centre.lat * RAD),
(lat - centre.lat) * 110_540,
),
),
);
assert.ok(farthest < 200, `nothing from the corner (${farthest} m)`);
coverage.clear();
});
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { createMapillaryProvider, mapillaryImageUrl } from './index.js';
import { sequenceIdFromPick } from './coverage.js';
import { thinImages } from './sequences.js';
import { validateProviders } from '../../registry.js';
import { PROVIDER_COLORS } from '../../policy.js';
import { resolveFilter } from '../../filter.js';
@@ -135,3 +136,47 @@ test('a "since N days" window follows the clock in a long-open tab (review P3)',
'two days on, the image is older than the window',
);
});
test('nearestImage measures the short way round the date line (review IC8 P2)', async () => {
const at = (id, lon, lat) => ({
id,
is_pano: false,
captured_at: 10,
geometry: { type: 'Point', coordinates: [lon, lat] },
});
const source = fakeSource({
nearest: [
at('same-side', 179.9995, 0), // ~45 m west, on the camera's side
at('across', -179.9999, 0), // ~11 m east, across ±180°
],
});
const instance = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(
await instance.nearestImage({ lat: 0, lon: 179.9999 }),
'across',
);
// And from the other side.
const west = createMapillaryProvider({
source: fakeSource({
nearest: [at('same-side', -179.9995, 0), at('across', 179.9999, 0)],
}),
}).create(fakeContext());
assert.equal(await west.nearestImage({ lat: 0, lon: -179.9999 }), 'across');
});
test('cone thinning across the date line drops images metres apart (review IC8 P2)', () => {
const image = (id, lon) => ({ id, lon, lat: 0 });
// A sequence driving east over ±180° with images ~1 m apart, then ~11 m on.
const kept = thinImages(
[
image('a', 179.99999),
image('b', -179.99999), // ~2 m from a, across the date line
image('c', -179.9999), // ~11 m from a
],
3,
);
assert.deepEqual(
kept.map(({ id }) => id),
['a', 'c'],
);
});
@@ -2,6 +2,8 @@ import * as Cesium from 'cesium';
import { imageConeGlyph } from '../../glyphs.js';
import { passesImageryFilter } from '../../filter.js';
import { refineHeights } from '../../groundCast.js';
import { createHorizonCull } from '../../view.js';
import { meshCellKey } from '../../meshSampler.js';
import { metresBetween } from '../../view.js';
import {
COLORS,
@@ -57,6 +59,21 @@ export function createSequences({ state, source, parts }) {
state.context.notify();
}
/**
* The cones on the globe: each drawn image's index, billboard and height
* (null while clamped), and the mesh cells under them, so a mesh sample
* moves only the cones it can affect.
*/
const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() });
let drawn = nothingDrawn();
// The cones skip the depth test: hide the ones behind the globe.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: () => drawn.cones.map((cone) => cone.billboard),
onChange: requestRender,
});
function ensureCollections(viewer) {
if (state.sequence.collection) return;
state.sequence.collection = new Cesium.BillboardCollection({
@@ -69,6 +86,23 @@ export function createSequences({ state, source, parts }) {
function clearCones() {
state.sequence.collection?.removeAll();
state.sequence.images = [];
drawn = nothingDrawn();
horizon.stop();
}
/** Where a cone stands: at `height`, or clamped to the ground when null. */
function placement(image, height) {
return {
position: Cesium.Cartesian3.fromDegrees(
image.lon,
image.lat,
height ?? 0,
),
heightReference:
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE,
};
}
/**
@@ -78,7 +112,7 @@ export function createSequences({ state, source, parts }) {
* draped, or when a terrain height is not cached yet; that fetches the
* heights and draws the cones again.
*/
function coneHeights(images) {
function coneHeights(images, { request = true } = {}) {
const ground = state.context.groundCaster;
if (state.context.getSurface?.() !== 'terrain' || !ground) return null;
const dems = images.map((image) => ground.groundAt(image.lon, image.lat));
@@ -91,7 +125,8 @@ export function createSequences({ state, source, parts }) {
return null;
}
const sampler = state.context.meshSampler;
sampler?.request(images.map((image) => [image.lon, image.lat]));
if (request)
sampler?.request(images.map((image) => [image.lon, image.lat]));
return refineHeights(
images.map((image, i) => ({
dem: dems[i],
@@ -104,6 +139,7 @@ export function createSequences({ state, source, parts }) {
const collection = state.sequence.collection;
if (!collection) return;
collection.removeAll();
drawn = { ...nothingDrawn(), images };
const heights = coneHeights(images);
// One colour per source; 360° images keep the ring shape.
const cone = imageConeGlyph({ size: 32, color: COLORS.coverage });
@@ -117,28 +153,56 @@ export function createSequences({ state, source, parts }) {
images.forEach((image, index) => {
if (!passesImageryFilter(image, filter)) return;
const height = heights?.[index] ?? null;
collection.add({
const billboard = collection.add({
id: `${PICK_PREFIX.image}${image.id}`,
position: Cesium.Cartesian3.fromDegrees(
image.lon,
image.lat,
height ?? 0,
),
...placement(image, height),
image: image.isPano ? ring : cone,
imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone',
width: IMAGE_CONE_SIZE_PX,
height: IMAGE_CONE_SIZE_PX,
rotation: -Cesium.Math.toRadians(image.compassAngle),
alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference:
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
});
drawn.cones.push({ index, billboard, height });
drawn.cells.add(meshCellKey(image.lon, image.lat));
});
horizon.update();
requestRender();
}
/**
* New mesh samples landed: when any is under a cone, re-place the cones
* whose cast height changed, in place. A sample can move a neighbour too
* (refineHeights bridges across covered points), so every height is
* recomputed (cheap arithmetic) but only moved cones are touched.
*/
function onMeshSampled(batch) {
if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length)
return;
if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat))))
return;
const { images, cones } = drawn;
// Already asked for when drawn; a missing terrain cell redraws them all.
const heights = coneHeights(images, { request: false });
if (!heights) return;
const moved = [];
for (const cone of cones) {
const height = heights[cone.index] ?? null;
if (height === cone.height) continue;
cone.height = height;
const { position, heightReference } = placement(
images[cone.index],
height,
);
cone.billboard.heightReference = heightReference;
cone.billboard.position = position;
moved.push(cone.billboard);
}
if (!moved.length) return;
horizon.update(moved);
requestRender();
}
@@ -235,9 +299,7 @@ export function createSequences({ state, source, parts }) {
}
// New mesh samples may move the cones onto (or off) the road surface.
state.context.meshSampler?.onSampled(() => {
if (state.context.getSurface?.() === 'terrain') rerender();
});
const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled);
function setVisible(visible) {
if (state.sequence.collection) state.sequence.collection.show = visible;
@@ -246,6 +308,9 @@ export function createSequences({ state, source, parts }) {
function destroy(viewer) {
state.sequence.abort?.abort();
horizon.stop();
drawn = nothingDrawn();
stopMeshListener?.();
const collection = state.sequence.collection;
if (collection) {
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { MESH_CELL_DEG } from '../../meshSampler.js';
import { createSequences } from './sequences.js';
// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op.
@@ -170,3 +172,155 @@ test('clearSelection cancels the lookup and uncolours the sequence', async () =>
await settle();
assert.deepEqual(drawn(), [], 'the late answer draws nothing');
});
/**
* Terrain mode (Google 3D at street zoom): bare earth 10 m everywhere, a
* mesh sampler whose samples the test sets and announces, and cones that
* log every change made to them or their collection.
*/
function terrainSetup() {
const context = setup();
const { state } = context;
const mesh = new Map();
const cell = (lon, lat) =>
`${Math.round(lon / MESH_CELL_DEG)},${Math.round(lat / MESH_CELL_DEG)}`;
let announce = null;
state.context.getSurface = () => 'terrain';
state.context.groundCaster = {
groundAt: () => 10,
prepare: async () => true,
};
state.context.meshSampler = {
request() {},
meshAt: (lon, lat) => mesh.get(cell(lon, lat)),
onSampled: (listener) => {
announce = listener;
return () => (announce = null);
},
};
const changes = [];
const items = [];
state.sequence.collection = {
items,
show: true,
add(options) {
changes.push(['add', options.id]);
const cone = new Proxy(options, {
set(target, key, value) {
changes.push([key, target.id]);
target[key] = value;
return true;
},
});
items.push(cone);
return cone;
},
removeAll() {
changes.push(['removeAll']);
items.length = 0;
},
};
// Created again, so it listens to this sampler.
const sequences = createSequences({
state,
source: context.source,
parts: { coverage: { recolorSequence() {} } },
});
/** Sample the mesh under a point, then announce it as the sampler does. */
function sample(lon, lat, height) {
const centre = [
Math.round(lon / MESH_CELL_DEG) * MESH_CELL_DEG,
Math.round(lat / MESH_CELL_DEG) * MESH_CELL_DEG,
];
mesh.set(cell(lon, lat), height);
announce([centre]);
}
const heightOf = (cone) =>
Cesium.Cartographic.fromCartesian(cone.position).height;
return { ...context, sequences, changes, items, sample, heightOf };
}
test('a mesh sample away from the selected sequence changes no cone (review IC8 P1)', async () => {
const { source, sequences, changes, items, sample } = terrainSetup();
sequences.select('A');
source.last('A').resolve(records('A', 3));
await settle();
assert.equal(items.length, 3);
changes.length = 0;
sample(-121.3, 38.7, 12); // a street elsewhere in the tile
assert.deepEqual(changes, [], 'nothing rebuilt or moved');
});
test('a mesh sample under a cone moves that cone in place, and no other (review IC8 P1)', async () => {
const { source, sequences, changes, items, sample, heightOf } =
terrainSetup();
sequences.select('A');
source.last('A').resolve(records('A', 3));
await settle();
const before = items.map(heightOf);
changes.length = 0;
// The road under the second image is 2.5 m above the bare earth.
sample(-121.489, 38.58, 12.5);
assert.ok(
changes.every(([, id]) => id === 'mly:img:A-1'),
`only A-1 changed: ${JSON.stringify(changes)}`,
);
assert.ok(
changes.some(([key]) => key === 'position'),
'A-1 moved',
);
assert.equal(
changes.some(([key]) => key === 'removeAll' || key === 'add'),
false,
'the collection is not rebuilt',
);
const after = items.map(heightOf);
assert.ok(
Math.abs(after[1] - 13.5) < 0.01,
`A-1 stands on the road (${after[1]})`,
);
assert.deepEqual([after[0], after[2]], [before[0], before[2]]);
});
test('cones behind the globe are hidden, and show again from the other side (review IC8 P2)', async () => {
const { state, source, sequences } = setup();
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
const preRender = new Set();
state.viewer = {
camera: {
get positionWC() {
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
},
},
scene: {
preRender: {
addEventListener(listener) {
preRender.add(listener);
return () => preRender.delete(listener);
},
},
},
};
sequences.select('A');
// One image under the camera, one near its antipode.
source.last('A').resolve([
...records('A', 1),
{
id: 'A-far',
geometry: { coordinates: [58.51, -38.58] },
captured_at: 1_800_000_000_000,
},
]);
await settle();
const [near, far] = state.sequence.collection.items;
assert.equal(near.show, true, 'the near side stays visible');
assert.equal(far.show, false, 'the far side does not show through');
// Fly round to the other hemisphere: the two swap.
view.lon = 58.51;
view.lat = -38.58;
for (const listener of [...preRender]) listener();
assert.equal(near.show, false);
assert.equal(far.show, true);
sequences.clearSelection();
assert.equal(preRender.size, 0, 'the cull listener is gone');
});
+10 -2
View File
@@ -9,6 +9,15 @@ function clampLat(lat) {
return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
}
/**
* A longitude, or the difference of two, wrapped into [-180, 180]: the short
* way round the date line. Values already in range come back unchanged.
*/
export function wrapLon(lon) {
if (lon >= -180 && lon <= 180) return lon;
return ((((lon + 180) % 360) + 360) % 360) - 180;
}
/** Fractional tile column of a longitude in [-180, 180] at zoom z. */
function tileXAt(lon, z) {
return ((lon + 180) / 360) * 2 ** z;
@@ -27,8 +36,7 @@ export function lonToTileX(lon, z) {
const n = 2 ** z;
// 180° is the east edge of the last column, not the west edge of the first.
if (lon >= 180) return n - 1;
const wrapped = ((((lon + 180) % 360) + 360) % 360) - 180;
return Math.min(n - 1, Math.max(0, Math.floor(tileXAt(wrapped, z))));
return Math.min(n - 1, Math.max(0, Math.floor(tileXAt(wrapLon(lon), z))));
}
/** Tile row for a latitude at zoom z. */
+12
View File
@@ -9,6 +9,7 @@ import {
tileBounds,
tileLocalToLonLat,
tilesForBbox,
wrapLon,
} from './tileMath.js';
test('lon/lat to tile matches the Sacramento reference tile', () => {
@@ -125,3 +126,14 @@ test('a focus ranks tiles along the line of sight, not from the box centre (revi
assert.ok(keys.includes(centre), 'the tile at the centre of the screen');
assert.equal(ranked.total > 9, true);
});
test('wrapLon wraps longitudes and their differences into [-180, 180]', () => {
assert.equal(wrapLon(10.0005), 10.0005, 'in range: unchanged');
assert.equal(wrapLon(180), 180);
assert.equal(wrapLon(-180), -180);
assert.ok(Math.abs(wrapLon(180.001) - -179.999) < 1e-9);
assert.ok(Math.abs(wrapLon(-359.9998) - 0.0002) < 1e-9);
assert.ok(Math.abs(wrapLon(359.9998) - -0.0002) < 1e-9);
assert.ok(Math.abs(wrapLon(540.5) - -179.5) < 1e-9);
assert.ok(Number.isNaN(wrapLon(NaN)));
});
+69 -2
View File
@@ -1,13 +1,15 @@
import * as Cesium from 'cesium';
import { wrapLon } from './tileMath.js';
/**
* Approximate metres between two {lon, lat} points: equirectangular, good
* for the street-scale distances the layer measures.
* for the street-scale distances the layer measures. Measured the short way
* round, so neighbours either side of ±180° are metres apart, not a world.
*/
export function metresBetween(a, b) {
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
return Math.hypot(
(b.lon - a.lon) * 111_320 * Math.cos(lat),
wrapLon(b.lon - a.lon) * 111_320 * Math.cos(lat),
(b.lat - a.lat) * 110_540,
);
}
@@ -248,3 +250,68 @@ export function viewFocus(
}
return { nadir, ahead };
}
/**
* Hide billboards and points behind the horizon, as the cyclones layer does.
* Street Level's skip the depth test, so Google 3D and clouds never hide the
* near side's; nothing else then stops the far hemisphere's drawing through
* the globe, and a finite skip distance cannot, as the horizon's distance
* moves with the camera. Re-culls on pre-render only once the camera moved,
* and stops listening when there is nothing left to cull.
* @param {{getViewer: () => object|null, items: () => Iterable<{position: object, show: boolean}>, onChange?: () => void}} options
*/
export function createHorizonCull({ getViewer, items, onChange }) {
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
let from = null;
let stopListening = null;
/** Show what is in front of the horizon, hide the rest; the count seen. */
function cull(list, position) {
occluder.cameraPosition = position;
let seen = 0;
let changed = false;
for (const item of list) {
seen++;
const show = occluder.isPointVisible(item.position);
if (item.show !== show) {
item.show = show;
changed = true;
}
}
if (changed) onChange?.();
return seen;
}
/** Cull everything from the camera at `position`; the count seen. */
function cullAll(position) {
from = Cesium.Cartesian3.clone(position, from);
return cull(items(), position);
}
function onPreRender() {
const position = getViewer()?.camera?.positionWC;
if (!position || (from && Cesium.Cartesian3.equals(from, position))) return;
if (!cullAll(position)) stop();
}
/**
* Cull `added` (new or moved items), or everything, now; then everything
* again whenever the camera moves.
*/
function update(added) {
const viewer = getViewer();
const position = viewer?.camera?.positionWC;
if (position && !(added ? cull(added, position) : cullAll(position)))
return;
stopListening ||=
viewer?.scene?.preRender?.addEventListener(onPreRender) || null;
}
function stop() {
stopListening?.();
stopListening = null;
from = null;
}
return { update, stop };
}
+21
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import * as Cesium from 'cesium';
import {
cameraHeightAboveGround,
metresBetween,
viewCentre,
viewFocus,
visibleBbox,
@@ -146,3 +147,23 @@ test('looking at the horizon from eye height still boxes the ground around the c
assert.ok(north - 38.5816 > 0.008 && 38.5816 - south > 0.008);
assert.ok(east - -121.4944 > 0.008 && -121.4944 - west > 0.008);
});
test('metresBetween measures the short way round the date line (review IC8 P2)', () => {
const east = { lon: 179.9999, lat: 0 };
const west = { lon: -179.9999, lat: 0 };
// 0.0002° of longitude at the equator, not 40,000 km round the other way.
assert.ok(Math.abs(metresBetween(east, west) - 22.264) < 1e-6);
assert.ok(Math.abs(metresBetween(west, east) - 22.264) < 1e-6);
assert.ok(
Math.abs(
metresBetween({ lon: 180, lat: 0 }, { lon: -180, lat: 0.0001 }) - 11.054,
) < 1e-6,
'the two names of the same meridian',
);
// Away from the date line nothing changes.
assert.ok(
Math.abs(
metresBetween({ lon: 10, lat: 0 }, { lon: 10.001, lat: 0 }) - 111.32,
) < 1e-9,
);
});