diff --git a/src/layers/streetLevel/groundCast.js b/src/layers/streetLevel/groundCast.js index e181d59d2..9698a599f 100644 --- a/src/layers/streetLevel/groundCast.js +++ b/src/layers/streetLevel/groundCast.js @@ -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 }, diff --git a/src/layers/streetLevel/groundCast.test.mjs b/src/layers/streetLevel/groundCast.test.mjs index 740f228a1..4e80a7e32 100644 --- a/src/layers/streetLevel/groundCast.test.mjs +++ b/src/layers/streetLevel/groundCast.test.mjs @@ -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}`); +}); diff --git a/src/layers/streetLevel/marker.js b/src/layers/streetLevel/marker.js index 8a06e9068..8f266e0ef 100644 --- a/src/layers/streetLevel/marker.js +++ b/src/layers/streetLevel/marker.js @@ -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); diff --git a/src/layers/streetLevel/marker.test.mjs b/src/layers/streetLevel/marker.test.mjs new file mode 100644 index 000000000..c20fc58c0 --- /dev/null +++ b/src/layers/streetLevel/marker.test.mjs @@ -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); +}); diff --git a/src/layers/streetLevel/meshSampler.js b/src/layers/streetLevel/meshSampler.js index eeb819ac8..286039dad 100644 --- a/src/layers/streetLevel/meshSampler.js +++ b/src/layers/streetLevel/meshSampler.js @@ -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; diff --git a/src/layers/streetLevel/meshSampler.test.mjs b/src/layers/streetLevel/meshSampler.test.mjs index e4478767a..5c06b9163 100644 --- a/src/layers/streetLevel/meshSampler.test.mjs +++ b/src/layers/streetLevel/meshSampler.test.mjs @@ -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(); +}); diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index 8d3fb1980..1228c5ab6 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -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(); }); diff --git a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs index 6ed700e5b..a7d5ae898 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs @@ -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(); +}); diff --git a/src/layers/streetLevel/providers/mapillary/provider.test.mjs b/src/layers/streetLevel/providers/mapillary/provider.test.mjs index b5ee92c18..0d2bcba1f 100644 --- a/src/layers/streetLevel/providers/mapillary/provider.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/provider.test.mjs @@ -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'], + ); +}); diff --git a/src/layers/streetLevel/providers/mapillary/sequences.js b/src/layers/streetLevel/providers/mapillary/sequences.js index 17074fc5a..e02462f19 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.js +++ b/src/layers/streetLevel/providers/mapillary/sequences.js @@ -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); diff --git a/src/layers/streetLevel/providers/mapillary/sequences.test.mjs b/src/layers/streetLevel/providers/mapillary/sequences.test.mjs index 4ea1aeac1..d8a3bc417 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/sequences.test.mjs @@ -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'); +}); diff --git a/src/layers/streetLevel/tileMath.js b/src/layers/streetLevel/tileMath.js index 2267aa21c..6b3395853 100644 --- a/src/layers/streetLevel/tileMath.js +++ b/src/layers/streetLevel/tileMath.js @@ -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. */ diff --git a/src/layers/streetLevel/tileMath.test.mjs b/src/layers/streetLevel/tileMath.test.mjs index ff367a280..3231f956b 100644 --- a/src/layers/streetLevel/tileMath.test.mjs +++ b/src/layers/streetLevel/tileMath.test.mjs @@ -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))); +}); diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index 45af1ecb2..a90185f77 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -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 }; +} diff --git a/src/layers/streetLevel/view.test.mjs b/src/layers/streetLevel/view.test.mjs index f2e3fe038..9c1971cfe 100644 --- a/src/layers/streetLevel/view.test.mjs +++ b/src/layers/streetLevel/view.test.mjs @@ -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, + ); +});