diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d5627c69..837ddd00f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - Street Level draws each imagery source in one colour, everywhere it appears: its PROVIDERS chip, coverage lines, overview points, image cones and a single legend swatch. Mapillary is green (no more age dimming or magenta panoramas; 360° cones are rings, and the 360°/FLAT filter still separates them); Panoramax will be purple and Google Street View blue. -- Street Level coverage no longer floats over trees and bridges on the Google 3D map. At street zoom, sequence lines, image cones and the position marker are placed on the bare earth from the terrain service (with a 2 m lift) instead of being draped on the top of the photoreal mesh, so buildings and tree canopies hide what is behind them; other maps and higher views keep draped lines. Opening a photo no longer flies the camera underground when the Google tiles under it have not loaded yet. +- Street Level coverage no longer floats over trees and bridges on the Google 3D map. At street zoom, sequence lines, image cones and the position marker are placed on the bare earth from the terrain service (with a 2 m lift) instead of being draped on the top of the photoreal mesh, so buildings and tree canopies hide what is behind them; other maps and higher views keep draped lines. Opening a photo no longer flies the camera underground when the Google tiles under it have not loaded yet. Near the camera the placement is refined against the rendered Google 3D surface, so lines also follow freeways in trenches and steep streets, and stay on the road under trees. - Public Overpass instances are no longer used by default. Street Traffic roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cf6fd6cd0..e2a6396b2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2900,7 +2900,17 @@ buildings and trees. Heights are sampled on the ground-floor grid (0.001°, tile needs at most a few hundred lookups, sent once per tile and aborted when the tile is dropped. A tile is drawn draped first and swapped for its cast lines when the heights arrive; a geoid fallback (terrain proxy down) counts as -unknown and leaves the lines draped. Cones and the marker keep drawing over +unknown and leaves the lines draped. The bare-earth grid cannot see a +freeway in a trench, a steep street between grid corners or the road under a +tree, so `src/layers/streetLevel/meshSampler.js` then samples the rendered +Google 3D surface (`scene.sampleHeight`, overlays excluded) once per ~11 m +cell within 900 m of the camera, nearest first, in idle time within a 6 ms +budget, and `refineHeights` blends it in: mesh up to 3 m above bare earth is +the road and is followed 1 m above it; a higher mesh (canopy, deck, roof) takes +the road's offset from bare earth carried across from the road samples on +either side; anything else stays bare earth + 2 m. Tiles touched by new +samples are redrawn one per idle slice with the same no-blink swap. Roads on +elevated decks are still drawn at ground level under the deck. Cones and the marker keep drawing over buildings so they stay clickable. Framing or following a photo also checks the sampled mesh height against the bare earth: `sampleHeight` can return values kilometres underground before the tiles under the photo have loaded, and such diff --git a/scripts/format-scope.json b/scripts/format-scope.json index dddf20ab3..f442396ca 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -871,6 +871,7 @@ "src/layers/streetLevel/pickRouter.js", "src/layers/streetLevel/uiState.js", "src/layers/streetLevel/groundCast.js", + "src/layers/streetLevel/meshSampler.js", "src/layers/streetLevel/view.js", "src/layers/streetLevel/marker.js", "src/layers/streetLevel/cameraFollow.js", @@ -901,6 +902,7 @@ "src/layers/streetLevel/pickRouter.test.mjs", "src/layers/streetLevel/uiState.test.mjs", "src/layers/streetLevel/groundCast.test.mjs", + "src/layers/streetLevel/meshSampler.test.mjs", "src/layers/streetLevel/view.test.mjs", "src/layers/streetLevel/providers/mapillary/provider.test.mjs", "src/layers/streetLevel/providers/mapillary/decode.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9e38c3a2d..c406693a5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1169,6 +1169,7 @@ "src/layers/streetLevel/view.js", "src/layers/streetLevel/uiState.js", "src/layers/streetLevel/groundCast.js", + "src/layers/streetLevel/meshSampler.js", "src/layers/streetLevel/pickRouter.js", "src/layers/streetLevel/params.js", "src/layers/streetLevel/registry.js", @@ -2080,6 +2081,7 @@ "src/layers/streetLevel/view.js", "src/layers/streetLevel/uiState.js", "src/layers/streetLevel/groundCast.js", + "src/layers/streetLevel/meshSampler.js", "src/layers/streetLevel/pickRouter.js", "src/layers/streetLevel/params.js", "src/layers/streetLevel/registry.js", @@ -2590,6 +2592,7 @@ "src/layers/streetLevel/view.js", "src/layers/streetLevel/uiState.js", "src/layers/streetLevel/groundCast.js", + "src/layers/streetLevel/meshSampler.js", "src/layers/streetLevel/pickRouter.js", "src/layers/streetLevel/params.js", "src/layers/streetLevel/registry.js", diff --git a/src/layers/streetLevel/groundCast.js b/src/layers/streetLevel/groundCast.js index d251d1c06..aaf4f7f96 100644 --- a/src/layers/streetLevel/groundCast.js +++ b/src/layers/streetLevel/groundCast.js @@ -25,6 +25,66 @@ export const GROUND_CAST_MAX_CORNERS = 1024; /** Cached grid corners before the cache is dropped and refilled. */ const GROUND_CAST_CACHE_MAX = 50_000; +/** + * Mesh refinement. The bare-earth grid cannot see a freeway in a trench, a + * steep street between grid corners, or the road under a tree, so where the + * rendered Google 3D surface has been sampled it refines the height: + * - mesh at most MESH_ROAD_ABOVE_M above bare earth (and not absurdly far + * below it) is the road itself: follow it, MESH_LIFT_M above; + * - mesh higher than that is something over the road (canopy, deck, roof): + * carry the road's offset from bare earth across from the nearest road + * samples on both sides, within MESH_BRIDGE_MAX_POINTS; + * - otherwise bare earth plus GROUND_CAST_LIFT_M, as without the mesh. + */ +export const MESH_ROAD_ABOVE_M = 3; +/** A sample this far below bare earth is a bad probe, not a road. */ +export const MESH_ROAD_BELOW_M = 30; +/** Metres above the sampled road surface. */ +export const MESH_LIFT_M = 1; +/** Longest run of covered points bridged from the road on either side. */ +export const MESH_BRIDGE_MAX_POINTS = 8; +/** Line spacing, in degrees (~22 m), while the mesh refines heights. */ +export const MESH_DENSIFY_DEG = 0.0002; + +/** + * Heights for a line's points from bare earth and, where sampled, the mesh. + * Pure, so the rule above is testable without a scene. + * @param {Array<{dem: number, mesh?: number|null}>} points + * @param {{lift?: number}} [options] + * @returns {Array} + */ +export function refineHeights(points, { lift = GROUND_CAST_LIFT_M } = {}) { + const road = points.map(({ dem, mesh }) => + Number.isFinite(mesh) && + mesh <= dem + MESH_ROAD_ABOVE_M && + mesh >= dem - MESH_ROAD_BELOW_M + ? mesh - dem + : null, + ); + return points.map(({ dem, mesh }, i) => { + if (road[i] !== null) return dem + road[i] + MESH_LIFT_M; + if (Number.isFinite(mesh) && mesh > dem + MESH_ROAD_ABOVE_M) { + let a = i - 1; + while (a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS && road[a] === null) a--; + let b = i + 1; + while ( + b < points.length && + b - i <= MESH_BRIDGE_MAX_POINTS && + road[b] === null + ) + b++; + const left = a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS ? road[a] : null; + const right = + b < points.length && b - i <= MESH_BRIDGE_MAX_POINTS ? road[b] : null; + if (left !== null && right !== null) { + const t = (i - a) / (b - a); + return dem + left + (right - left) * t + MESH_LIFT_M; + } + } + return dem + lift; + }); +} + /** Camera height (m above ground) below which Google 3D switches to terrain mode. */ export const SURFACE_TERRAIN_ENTER_M = 1400; /** Camera height above which terrain mode switches back to draped. */ @@ -177,16 +237,25 @@ export function createGroundCaster({ /** * A line as flat [lon, lat, height, ...] degrees and metres, densified; - * null when any corner it needs is not cached yet. + * null when any corner it needs is not cached yet. With `meshAt` (sampled + * mesh heights, undefined or null where unknown) the line is densified + * finer and its heights refined by `refineHeights`. * @param {Array<[number, number]>} coords + * @param {{meshAt?: (lon: number, lat: number) => number|null|undefined}} [options] */ - function castLine(coords) { - const flat = []; - for (const [lon, lat] of densifyLine(coords)) { - const height = heightAt(lon, lat); - if (height === null) return null; - flat.push(lon, lat, height); + function castLine(coords, { meshAt = null } = {}) { + const rows = []; + const points = meshAt + ? densifyLine(coords, MESH_DENSIFY_DEG) + : densifyLine(coords); + for (const [lon, lat] of points) { + const dem = groundAt(lon, lat); + if (dem === null) return null; + rows.push({ lon, lat, dem, mesh: meshAt ? meshAt(lon, lat) : null }); } + const heights = refineHeights(rows, { lift }); + const flat = []; + rows.forEach(({ lon, lat }, i) => flat.push(lon, lat, heights[i])); return flat; } diff --git a/src/layers/streetLevel/groundCast.test.mjs b/src/layers/streetLevel/groundCast.test.mjs index 05b24f963..b8402c351 100644 --- a/src/layers/streetLevel/groundCast.test.mjs +++ b/src/layers/streetLevel/groundCast.test.mjs @@ -2,6 +2,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { GROUND_CAST_LIFT_M, + MESH_LIFT_M, + refineHeights, SURFACE_TERRAIN_ENTER_M, SURFACE_TERRAIN_EXIT_M, createGroundCaster, @@ -158,3 +160,62 @@ test('prepares run one at a time so neighbours share corners', async () => { test('the caster needs a terrain service', () => { assert.throws(() => createGroundCaster({ terrain: null }), TypeError); }); + +test('refineHeights follows the mesh where it is the road', () => { + // A freeway trench 6 m below the bare-earth grid, and a steep street 2 m above it. + const heights = refineHeights([ + { dem: 100, mesh: 94 }, + { dem: 100, mesh: 102 }, + { dem: 100, mesh: null }, + ]); + assert.deepEqual(heights, [ + 94 + MESH_LIFT_M, + 102 + MESH_LIFT_M, + 100 + GROUND_CAST_LIFT_M, + ]); +}); + +test('refineHeights carries the road under a canopy from both sides', () => { + // Road 1 m and 3 m above bare earth either side of a tree 12 m tall. + const heights = refineHeights([ + { dem: 50, mesh: 51 }, + { dem: 50, mesh: 62 }, + { dem: 50, mesh: 62 }, + { dem: 50, mesh: 53 }, + ]); + assert.equal(heights[0], 51 + MESH_LIFT_M); + assert.ok(Math.abs(heights[1] - (50 + 1 + 2 / 3 + MESH_LIFT_M)) < 1e-9); + assert.ok(Math.abs(heights[2] - (50 + 1 + 4 / 3 + MESH_LIFT_M)) < 1e-9); + assert.equal(heights[3], 53 + MESH_LIFT_M); +}); + +test('refineHeights keeps bare earth under a deck with no road beside it, and rejects bad probes', () => { + assert.deepEqual( + refineHeights([ + { dem: 20, mesh: 35 }, + { dem: 20, mesh: 35 }, + ]), + [20 + GROUND_CAST_LIFT_M, 20 + GROUND_CAST_LIFT_M], + ); + // A probe kilometres under the ground is not a road. + assert.deepEqual(refineHeights([{ dem: 20, mesh: -14000 }]), [ + 20 + GROUND_CAST_LIFT_M, + ]); +}); + +test('castLine refines heights with sampled mesh and densifies finer', async () => { + const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 }); + const line = [ + [10.0001, 50.0001], + [10.0009, 50.0001], + ]; + await caster.prepareLines([line]); + const plain = caster.castLine(line); + const meshed = caster.castLine(line, { meshAt: () => 98 }); + assert.ok(meshed.length > plain.length, 'finer spacing with the mesh'); + for (let i = 2; i < meshed.length; i += 3) + assert.equal(meshed[i], 98 + MESH_LIFT_M); + // Unsampled points keep the bare-earth height. + const unsampled = caster.castLine(line, { meshAt: () => undefined }); + assert.ok(unsampled[2] > 100 && unsampled[2] < 110); +}); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index dba3f96ed..2173efd83 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -11,6 +11,7 @@ import { decodeParams, encodeParams } from './params.js'; import { composeUIState, summarizeCoverage } from './uiState.js'; import { cameraHeightAboveGround, viewCentre } from './view.js'; import { createGroundCaster, nextSurfaceMode } from './groundCast.js'; +import { createMeshSampler } from './meshSampler.js'; /** Above this camera height the ground under the camera is not worth fetching. */ const SURFACE_WARM_BELOW_M = 6000; @@ -42,6 +43,9 @@ export function createStreetLevelLayer({ parts.groundCaster = services.terrain?.resolveEllipsoidalGround ? createGroundCaster({ terrain: services.terrain }) : null; + parts.meshSampler = parts.groundCaster + ? createMeshSampler({ getViewer: () => state.viewer }) + : null; parts.credits = createCredits(); parts.marker = createMarker(context); parts.follow = createCameraFollow(context); @@ -92,6 +96,8 @@ export function createStreetLevelLayer({ isActive: () => state.enabled && entry.on, /** Bare-earth heights (see groundCast.js), or null without a terrain service. */ groundCaster: parts.groundCaster, + /** Sampled Google 3D surface heights refining the cast (meshSampler.js), or null. */ + meshSampler: parts.meshSampler, /** 'terrain' when overlays should sit on the bare earth, else 'draped'. */ getSurface: () => state.surface, notify, @@ -158,6 +164,7 @@ export function createStreetLevelLayer({ }); if (mode === state.surface) return; state.surface = mode; + parts.meshSampler?.setEnabled(mode === 'terrain'); parts.marker.setSurface(mode); for (const entry of state.providers.values()) entry.instance.setSurface?.(mode); @@ -355,6 +362,7 @@ export function createStreetLevelLayer({ for (const entry of state.providers.values()) entry.instance.destroy(viewer); parts.marker.destroy(viewer); + parts.meshSampler?.destroy(); unsubscribeMapStack?.(); unsubscribeMapStack = null; mapStack = null; diff --git a/src/layers/streetLevel/marker.js b/src/layers/streetLevel/marker.js index 052448e70..8a06e9068 100644 --- a/src/layers/streetLevel/marker.js +++ b/src/layers/streetLevel/marker.js @@ -1,6 +1,7 @@ import * as Cesium from 'cesium'; import { positionMarkerGlyph } from './glyphs.js'; import { COLORS, POSITION_PICK_ID } from './policy.js'; +import { refineHeights } from './groundCast.js'; const SPRITE_ID = 'street-level:marker'; @@ -26,18 +27,32 @@ export function createMarker({ state, parts }) { sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection); } - /** Terrain height for the position, or null to clamp; fetches a missing cell. */ + /** + * Height for the position in terrain mode: bare earth, refined by the + * sampled Google 3D surface where it is known; null to clamp. Fetches a + * missing terrain cell and asks for a mesh sample under the marker. + */ function castHeight(position) { const ground = parts?.groundCaster; if (state.surface !== 'terrain' || !ground) return null; - const height = ground.heightAt(position.lon, position.lat); - if (height !== null) return height; - ground.prepare([[position.lon, position.lat]]).then((ready) => { - if (ready && last?.position === position) set(position, last.bearing); - }); - return null; + const { lon, lat } = position; + const dem = ground.groundAt(lon, lat); + if (dem === null) { + ground.prepare([[lon, lat]]).then((ready) => { + if (ready && last?.position === position) set(position, last.bearing); + }); + return null; + } + const mesh = parts.meshSampler?.meshAt(lon, lat); + if (mesh === undefined) parts.meshSampler?.request([[lon, lat]]); + return refineHeights([{ dem, mesh }])[0]; } + // A new mesh sample may move the marker onto (or off) the road surface. + parts?.meshSampler?.onSampled(() => { + if (last && state.surface === 'terrain') set(last.position, last.bearing); + }); + /** Move (or create) the marker; a null position removes it. */ function set(position, bearing) { const collection = state.marker.collection; diff --git a/src/layers/streetLevel/meshSampler.js b/src/layers/streetLevel/meshSampler.js new file mode 100644 index 000000000..a06bc6f43 --- /dev/null +++ b/src/layers/streetLevel/meshSampler.js @@ -0,0 +1,190 @@ +import * as Cesium from 'cesium'; + +/** + * Rendered-surface heights for ground casting on Google 3D (see groundCast.js + * refineHeights). One `scene.sampleHeight` probe per ~11 m cell, cached, so + * the many sequences that share a street share their samples. + * + * `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 + * nothing (tiles not streamed yet) is retried later rather than latched. + * Overlays are excluded from the probe, so only 3D tilesets are hit. + */ + +/** Cell size, in degrees (~11 m): samples are shared within a cell. */ +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. */ +export const MESH_SAMPLE_BUDGET_MS = 6; +/** A cell whose probe missed is tried again after this long. */ +export const MESH_MISS_RETRY_MS = 8000; +/** Cached cells before the cache is dropped and refilled. */ +const MESH_CACHE_MAX = 80_000; +/** Listeners hear about new samples at most this often. */ +const MESH_NOTIFY_MS = 700; + +const cellOf = (value) => Math.round(value / MESH_CELL_DEG); +const keyOf = (lon, lat) => `${cellOf(lon)},${cellOf(lat)}`; + +function metres([lon1, lat1], [lon2, lat2]) { + const lat = (((lat1 + lat2) / 2) * Math.PI) / 180; + return Math.hypot( + (lon2 - lon1) * 111_320 * Math.cos(lat), + (lat2 - lat1) * 110_540, + ); +} + +/** Every top-level primitive that is not a 3D tileset: what a probe skips. */ +function overlays(scene) { + const out = []; + const primitives = scene.primitives; + for (let i = 0; i < primitives.length; i++) { + const primitive = primitives.get(i); + if (!(primitive instanceof Cesium.Cesium3DTileset)) out.push(primitive); + } + return out; +} + +function idle(task) { + if (typeof globalThis.requestIdleCallback === 'function') + globalThis.requestIdleCallback(task, { timeout: 300 }); + else setTimeout(() => task(null), 16); +} + +/** + * @param {{getViewer: () => object|null, budgetMs?: number, radiusM?: number}} options + */ +export function createMeshSampler({ + getViewer, + budgetMs = MESH_SAMPLE_BUDGET_MS, + radiusM = MESH_SAMPLE_RADIUS_M, +}) { + /** cell key → sampled mesh height (ellipsoidal metres). */ + const heights = new Map(); + /** cell key → retry time for probes that hit nothing. */ + const misses = new Map(); + /** cell key → [lon, lat] waiting to be probed. */ + const wanted = new Map(); + const listeners = new Set(); + let enabled = false; + let running = false; + let fresh = []; + let notifyTimer = null; + + /** The sampled mesh height for a point, or undefined when not sampled. */ + function meshAt(lon, lat) { + return heights.get(keyOf(lon, lat)); + } + + /** Queue the cells under these [lon, lat] points for probing. */ + 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; + if ((misses.get(key) || 0) > now) continue; + wanted.set(key, [ + cellOf(lon) * MESH_CELL_DEG, + cellOf(lat) * MESH_CELL_DEG, + ]); + } + schedule(); + } + + function schedule() { + if (running || !enabled || !wanted.size) return; + running = true; + idle(step); + } + + function emit() { + notifyTimer = null; + const batch = fresh; + fresh = []; + for (const listener of [...listeners]) { + try { + listener(batch); + } catch (error) { + console.warn('[Data:StreetLevel] mesh listener error:', error); + } + } + } + + function step(deadline) { + running = false; + const scene = getViewer()?.scene; + const carto = getViewer()?.camera?.positionCartographic; + if (!enabled || !scene?.sampleHeightSupported || !carto) return; + const centre = [ + Cesium.Math.toDegrees(carto.longitude), + 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 = metres(point, centre); + if (distance > radiusM) wanted.delete(key); + else order.push([distance, key, point]); + } + 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; + wanted.delete(key); + probed++; + let height; + try { + height = scene.sampleHeight( + Cesium.Cartographic.fromDegrees(lon, lat), + exclude, + ); + } catch { + height = undefined; + } + if (!Number.isFinite(height)) { + misses.set(key, Date.now() + MESH_MISS_RETRY_MS); + continue; + } + if (heights.size >= MESH_CACHE_MAX) heights.clear(); + heights.set(key, height); + fresh.push([lon, lat]); + } + if (fresh.length && !notifyTimer) + notifyTimer = setTimeout(emit, MESH_NOTIFY_MS); + schedule(); + } + + /** Probe only while overlays are cast (Google 3D at street zoom). */ + function setEnabled(on) { + enabled = on === true; + if (!enabled) wanted.clear(); + else schedule(); + } + + /** Hear about newly sampled cells, as [lon, lat] cell centres. */ + function onSampled(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + } + + function destroy() { + enabled = false; + wanted.clear(); + listeners.clear(); + clearTimeout(notifyTimer); + notifyTimer = null; + } + + return { meshAt, request, setEnabled, onSampled, destroy }; +} diff --git a/src/layers/streetLevel/meshSampler.test.mjs b/src/layers/streetLevel/meshSampler.test.mjs new file mode 100644 index 000000000..55568d74e --- /dev/null +++ b/src/layers/streetLevel/meshSampler.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { MESH_CELL_DEG, createMeshSampler } from './meshSampler.js'; + +const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms)); +const RAD = Math.PI / 180; + +/** A scene whose mesh is 100 m everywhere except where `holes` say. */ +function fakeViewer({ holes = () => false } = {}) { + const tileset = Object.create(Cesium.Cesium3DTileset.prototype); + const overlay = { name: 'lines' }; + const probes = []; + const scene = { + sampleHeightSupported: true, + primitives: { + length: 2, + get: (i) => [tileset, overlay][i], + }, + sampleHeight(carto, exclude) { + const lon = carto.longitude / RAD; + const lat = carto.latitude / RAD; + probes.push({ lon, lat, exclude }); + return holes(lon, lat) ? undefined : 100; + }, + }; + return { + probes, + overlay, + viewer: { + scene, + camera: { + positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD }, + }, + }, + }; +} + +test('cells are probed once, nearest first, with overlays excluded', async () => { + const { viewer, probes, overlay } = fakeViewer(); + const sampler = createMeshSampler({ getViewer: () => viewer }); + sampler.setEnabled(true); + const heard = []; + sampler.onSampled((batch) => heard.push(batch.length)); + sampler.request([ + [10.004, 50], + [10.001, 50], + [10.00101, 50.00001], // same ~11 m cell as the previous point + ]); + await settle(1000); + assert.equal(probes.length, 2); + assert.ok(probes[0].lon < probes[1].lon, 'nearest the camera first'); + assert.deepEqual(probes[0].exclude, [overlay], 'only tilesets are hit'); + assert.equal(sampler.meshAt(10.001, 50), 100); + assert.equal(sampler.meshAt(10.00101, 50.00001), 100); + assert.deepEqual(heard, [2]); + sampler.request([[10.001, 50]]); + await settle(); + assert.equal(probes.length, 2, 'a sampled cell is not probed again'); + sampler.destroy(); +}); + +test('far cells are skipped, misses are not latched, and nothing runs while disabled', async () => { + const { viewer, probes } = fakeViewer({ holes: (lon) => lon > 10.0049 }); + const sampler = createMeshSampler({ getViewer: () => viewer }); + sampler.request([[10.001, 50]]); + await settle(); + assert.equal(probes.length, 0, 'disabled: nothing queued'); + sampler.setEnabled(true); + sampler.request([ + [10.05, 50], // ~3.6 km away + [10.005, 50], // a hole: tiles not streamed + ]); + await settle(); + assert.equal(probes.length, 1); + assert.equal(sampler.meshAt(10.05, 50), undefined); + assert.equal(sampler.meshAt(10.005, 50), undefined); + assert.ok(Math.abs(probes[0].lon - 10.005) < MESH_CELL_DEG); + sampler.destroy(); +}); diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index c9c23c730..c4302606e 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -2,9 +2,12 @@ import * as Cesium from 'cesium'; import { decodeCoverageTile } from './decode.js'; import { passesImageryFilter } from '../../filter.js'; import { cameraHeightAboveGround, visibleBbox } from '../../view.js'; +import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js'; +import { MESH_CELL_DEG } from '../../meshSampler.js'; import { coverageZoomForHeight, overviewZoomForHeight, + tileBounds, tilesForBbox, } from '../../tileMath.js'; import { @@ -20,6 +23,10 @@ import { /** Draped lines stay at most this long while a tile's cast lines build. */ const SWAP_MAX_WAIT_MS = 4000; +/** Tiles touched by new mesh samples are redrawn at most this often. */ +const REMESH_INTERVAL_MS = 1500; +/** Gap between redrawing one dirty tile and the next. */ +const REMESH_STAGGER_MS = 120; /** Old-zoom tiles are kept at most this long after a zoom change. */ const STALE_TILE_MAX_MS = 6000; /** @@ -98,11 +105,12 @@ export function createCoverage({ state, source }) { */ function buildSequencePrimitives(sequences) { const ground = terrainMode() ? state.context.groundCaster : null; + const meshAt = ground ? state.context.meshSampler?.meshAt : undefined; const draped = []; const cast = []; for (const sequence of sequences) { if (!passesImageryFilter(sequence, filter())) continue; - const flat = ground?.castLine(sequence.coordinates); + const flat = ground?.castLine(sequence.coordinates, { meshAt }); let positions; try { positions = flat @@ -183,9 +191,94 @@ export function createCoverage({ state, source }) { entry.primitives = []; attachPrimitive(entry); removeWhenReady(entry, previous); + requestMesh(entry); }); } + /** The mesh cells under a tile's lines, built once per tile. */ + function meshCells(entry) { + if (entry.meshCells) return entry.meshCells; + const cells = new Map(); + for (const sequence of entry.sequenceList) + for (const [lon, lat] of densifyLine( + sequence.coordinates, + 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; + } + + /** Ask for mesh samples under a cast tile's lines (the sampler keeps the near ones). */ + function requestMesh(entry) { + const sampler = state.context.meshSampler; + if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return; + sampler.request(meshCells(entry)); + } + + /** + * New mesh samples landed: redraw the tiles they fall in, throttled, with + * the same no-blink swap as the bare-earth cast. + */ + function onMeshSampled(batch) { + if (!terrainMode() || !state.context.isActive()) return; + for (const entry of state.coverage.tiles.values()) { + if (entry.kind !== 'sequence' || !entry.bounds) continue; + const { west, south, east, north } = entry.bounds; + if ( + batch.some( + ([lon, lat]) => + lon >= west && lon <= east && lat >= south && lat <= north, + ) + ) + state.coverage.remeshDirty.add(entry); + } + if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return; + const wait = Math.max( + 0, + REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)), + ); + state.coverage.remeshTimer = setTimeout(remesh, wait); + } + + /** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */ + function remesh() { + state.coverage.remeshTimer = null; + state.coverage.remeshAt = Date.now(); + if (!terrainMode() || !state.context.isActive()) { + state.coverage.remeshDirty.clear(); + return; + } + const attached = new Set(state.coverage.tiles.values()); + const [entry] = state.coverage.remeshDirty; + if (!entry) return; + state.coverage.remeshDirty.delete(entry); + if (attached.has(entry)) { + const previous = entry.primitives; + entry.primitives = []; + attachPrimitive(entry); + removeWhenReady(entry, previous); + requestRender(); + } + if (state.coverage.remeshDirty.size) + state.coverage.remeshTimer = setTimeout( + () => idleTask(remesh), + REMESH_STAGGER_MS, + ); + } + + function idleTask(task) { + if (typeof globalThis.requestIdleCallback === 'function') + globalThis.requestIdleCallback(() => task(), { timeout: 500 }); + else task(); + } + + state.coverage.remeshDirty = new Set(); + state.context.meshSampler?.onSampled(onMeshSampled); + /** * Remove a tile's previous primitives once all its current ones are ready * (or after a few seconds); detaching the tile removes them at once. @@ -329,6 +422,15 @@ export function createCoverage({ state, source }) { return; const decoded = decodeCoverageTile(bytes, tile); const entry = { kind, primitive: null, primitives: [], count: 0 }; + // Padded: vector tiles carry a small buffer past their edge. + const bounds = tileBounds(tile.x, tile.y, tile.z); + const pad = (bounds.east - bounds.west) * 0.05; + entry.bounds = { + west: bounds.west - pad, + east: bounds.east + pad, + south: bounds.south - pad, + north: bounds.north + pad, + }; if (kind === 'sequence') { // Newest first, capped per tile so a dense city stays within budget. const sequences = decoded.sequences @@ -432,6 +534,10 @@ export function createCoverage({ state, source }) { state.coverage.pending.delete(key); } for (const tile of tiles) loadTile(tile, kind); + // The camera moved: cells that were out of the sampler's range may not be. + if (terrainMode()) + for (const entry of state.coverage.tiles.values()) + if (entry.castRequested) requestMesh(entry); notify(); } @@ -470,6 +576,9 @@ export function createCoverage({ state, source }) { purgeStale(); state.coverage.zoom = null; state.coverage.kind = null; + clearTimeout(state.coverage.remeshTimer); + state.coverage.remeshTimer = null; + state.coverage.remeshDirty.clear(); requestRender(); } diff --git a/src/layers/streetLevel/providers/mapillary/sequences.js b/src/layers/streetLevel/providers/mapillary/sequences.js index 7b73683a7..ea70489a7 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.js +++ b/src/layers/streetLevel/providers/mapillary/sequences.js @@ -1,6 +1,7 @@ import * as Cesium from 'cesium'; import { imageConeGlyph } from '../../glyphs.js'; import { passesImageryFilter } from '../../filter.js'; +import { refineHeights } from '../../groundCast.js'; import { COLORS, IMAGE_CONE_MIN_SPACING_M, @@ -78,24 +79,32 @@ export function createSequences({ state, source, parts }) { } /** - * Terrain heights for the cones in terrain mode (Google 3D at street zoom), - * so they stand on the street rather than on roofs and tree tops. Null when - * draped, or when some height is not cached yet; that fetches the heights - * and draws the cones again. + * Heights for the cones in terrain mode (Google 3D at street zoom), so they + * stand on the street rather than on roofs and tree tops: bare earth, + * refined along the sequence by the sampled Google 3D surface. Null when + * draped, or when a terrain height is not cached yet; that fetches the + * heights and draws the cones again. */ function coneHeights(images) { const ground = state.context.groundCaster; if (state.context.getSurface?.() !== 'terrain' || !ground) return null; - const heights = images.map((image) => - ground.heightAt(image.lon, image.lat), + const dems = images.map((image) => ground.groundAt(image.lon, image.lat)); + if (dems.includes(null)) { + ground + .prepare(images.map((image) => [image.lon, image.lat])) + .then((ready) => { + if (ready && state.sequence.images === images) renderCones(images); + }); + return null; + } + const sampler = state.context.meshSampler; + sampler?.request(images.map((image) => [image.lon, image.lat])); + return refineHeights( + images.map((image, i) => ({ + dem: dems[i], + mesh: sampler?.meshAt(image.lon, image.lat), + })), ); - if (!heights.includes(null)) return heights; - ground - .prepare(images.map((image) => [image.lon, image.lat])) - .then((ready) => { - if (ready && state.sequence.images === images) renderCones(images); - }); - return null; } function renderCones(images) { @@ -217,6 +226,11 @@ export function createSequences({ state, source, parts }) { if (state.sequence.images.length) renderCones(state.sequence.images); } + // New mesh samples may move the cones onto (or off) the road surface. + state.context.meshSampler?.onSampled(() => { + if (state.context.getSurface?.() === 'terrain') rerender(); + }); + function setVisible(visible) { if (state.sequence.collection) state.sequence.collection.show = visible; requestRender(); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 3b847e464..86d59029b 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -20,6 +20,7 @@ * @property {() => boolean} isActive Layer enabled and this provider switched on. * @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on the bare earth, not the mesh top. * @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service. + * @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js) that refine the cast; pass `meshAt` to `castLine`. * @property {() => void} notify Ask the core to publish a new UI snapshot. * @property {{openImage: (imageId: string) => Promise, reportError: (message: string|null) => void}} actions *