feat(street-level): place coverage on the bare earth on Google 3D

Draped ground polylines and clamped billboards land on the top of the
Google Photorealistic 3D mesh, so on a street under trees or an overpass
the coverage seemed to float at roof height.

At street zoom on Google 3D (camera below 1,400 m, back above 1,800 m)
the core switches to a `terrain` surface mode. groundCast.js samples the
existing terrain service on the ground-floor grid (0.001 deg), interpolates
bilinearly and adds 2 m; Mapillary lines become plain polylines at those
heights, and cones and the position marker stand on the street, so the
mesh hides what is behind buildings and trees. A tile draws draped first
and swaps to its cast lines once the heights arrive, keeping the old lines
until the new primitives are ready. Lookups are once per tile and aborted
when the tile is dropped; a geoid fallback counts as unknown and leaves
lines draped. Other map stacks and higher views are unchanged.

Providers get getSurface() and groundCaster in their context and an
optional setSurface(mode); getUIState() exposes `surface`. The QA gate
checks draped on Esri and terrain on Google 3D.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-09-28 21:16:54 +01:00
1 parent c7dc3146f2
commit d3d3898dbb
17 files changed
+832 -49

No files matched your search

+2
View File
@@ -8,6 +8,8 @@
- Street Level: a provider-neutral street-level imagery layer modelled on the iD editor's photo overlay. One panel in the right rail carries a chip per imagery provider, shared 360°/flat and captured-since filters, coverage drawn on the globe from orbit down to a single street, image cones per sequence, and one embedded viewer whose adapter follows the provider of the open image; each active provider credits its imagery on the globe, and share links carry the provider switches and the filter. Mapillary is the first provider (free client token; CC BY-SA 4.0 imagery); Google Street View, KartaView and Panoramax are next. The panel is kept simple: the viewer opens at the top so the photo needs no scrolling, the header pill and the provider chips switch the layer, and SINCE is a slider. FOLLOW (the globe camera following the street-level view) is available on the Google 3D map only.
- 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.
- CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived
from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the
flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD
+24 -2
View File
@@ -2786,6 +2786,25 @@ ground-clamped sequence lines (z0–5 overview points from orbit, z11–14
sequences below 60 km) and the proxy strips the unused `image` point layer
from z14 tiles in transit (12 MB → ~80 KB).
On Google 3D (`photoreal`) at street zoom the layer switches its surface mode
from `draped` to `terrain` (`getUIState().surface`; in below 1,400 m above the
ground, out above 1,800 m, so the mode does not flap). Draped ground polylines
and clamped billboards land on the top of the photoreal mesh, so on a street
under trees or an overpass the coverage appears to float at roof height. In
terrain mode `src/layers/streetLevel/groundCast.js` places lines, cones and the
position marker on the bare earth from the terrain service
(`/api/terrain/heights`) plus 2 m, and the mesh hides whatever is behind
buildings and trees. Heights are sampled on the ground-floor grid (0.001°,
~111 m), shared with other layers' cache, and interpolated bilinearly; a z14
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
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
a sample is ignored.
The panel has no separate ON/OFF or nearest-photo buttons. The header pill is
the layer switch, and a provider chip is lit only while the layer is on: with
one provider it is the layer switch too, and with several, darkening the last
@@ -2812,11 +2831,14 @@ Street-level providers implement the contract documented in
`requiresKeyId`, `pickPrefix`, `colors`, `credit`, `capabilities`, `legend`,
`externalUrl`, `create`) whose `create(context)` returns an instance with
`status`, `init`/`activate`/`deactivate`/`destroy`, `refreshCoverage`,
`setFilter`, `coverageStats`, `handlePick`, optional sequence selection,
`setFilter`, optional `setSurface`, `coverageStats`, `handlePick`, optional
sequence selection,
`nearestImage`, and a viewer adapter (`mount`/`open`/`close`/`unmount`/
`resize`/`onPose`) that emits a provider-neutral pose. The core routes clicks
by pick prefix, swaps viewer adapters in the one host, adds and removes each
provider's Cesium credit, and fans the filter out to every provider. Adding a
provider's Cesium credit, and fans the filter out to every provider. The
context also carries `getSurface()` and `groundCaster` (bare-earth heights),
so a provider can draw on the ground in terrain mode. Adding a
provider: implement the definition, register it in
`src/app/layers/streetLevel.js`, add its boolean option to the `street-level`
group in `src/data/layerState.js` and its modules to
+2
View File
@@ -857,6 +857,7 @@
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/cameraFollow.js",
@@ -886,6 +887,7 @@
"src/layers/streetLevel/params.test.mjs",
"src/layers/streetLevel/pickRouter.test.mjs",
"src/layers/streetLevel/uiState.test.mjs",
"src/layers/streetLevel/groundCast.test.mjs",
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
"src/ui/panelDock.test.mjs",
+3
View File
@@ -1130,6 +1130,7 @@
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
@@ -2020,6 +2021,7 @@
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
@@ -2505,6 +2507,7 @@
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
+64
View File
@@ -517,6 +517,70 @@ async function main() {
await setStack(original);
void stacks;
});
await step(
'coverage sits on the bare earth on Google 3D and drapes elsewhere',
async () => {
const surface = () =>
page.evaluate(() => {
const u = window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState();
return { surface: u.surface, count: u.coverage.count };
});
const setStack = (id) =>
page.evaluate(
(stackId) =>
window.__godsEyeView.mapStackController.setStack(stackId),
id,
);
const original = await page.evaluate(() =>
window.__godsEyeView.mapStackController.getActiveId(),
);
// FOLLOW left the camera at eye height; look down on the city again.
await park();
await sleep(1500);
await setStack('esri-imagery');
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState().surface === 'draped',
{ timeout: 10_000 },
);
const coverageLoaded = () =>
page.waitForFunction(
() => {
const u = window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState();
return u.coverage.count > 0 && !u.coverage.loading;
},
{ timeout: 60_000 },
);
await coverageLoaded();
assert.equal((await surface()).surface, 'draped', 'draped on Esri');
const photoreal = await page.evaluate(() =>
window.__godsEyeView.mapStackController.isStackAvailable('photoreal'),
);
if (photoreal) {
await setStack('photoreal');
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState().surface === 'terrain',
{ timeout: 15_000 },
);
await coverageLoaded();
assert.equal((await surface()).surface, 'terrain');
} else {
console.log(
' (Google 3D unavailable here: only the draped path ran)',
);
}
await setStack(original);
},
);
await step(
'EXPAND opens a modal dialog and Esc returns focus to the button',
async () => {
+1
View File
@@ -19,6 +19,7 @@ export function createApplicationStreetLevel({ surface, sources }) {
input,
render,
ground: surface?.groundFloor ?? null,
terrain: surface?.terrain ?? null,
},
});
}
+194
View File
@@ -0,0 +1,194 @@
/**
* Ground casting for street-level overlays on Google Photorealistic 3D.
*
* Draped lines and clamped billboards land on the top of the photoreal mesh,
* so on a street lined with trees or under an overpass the coverage appears
* to float at roof height. Casting places the overlays at the bare-earth
* height from the terrain service (DEM) instead, plus a small lift, and lets
* the mesh hide what is behind buildings.
*
* Heights are sampled on a coarse grid and interpolated, so one z14 coverage
* tile costs at most a few hundred terrain lookups, and the grid matches the
* ground-floor service (3 decimals) so cached cells are shared. Only real DEM
* heights count; a geoid fallback (terrain proxy down) is treated as unknown
* and the caller keeps its draped placement.
*/
/** 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. */
export const GROUND_CAST_DENSIFY_DEG = 0.0005;
/** Metres above the bare earth, so lines sit on the road instead of in it. */
export const GROUND_CAST_LIFT_M = 2;
/** Grid corners one prepare call may request; more stays draped. */
export const GROUND_CAST_MAX_CORNERS = 1024;
/** Cached grid corners before the cache is dropped and refilled. */
const GROUND_CAST_CACHE_MAX = 50_000;
/** 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. */
export const SURFACE_TERRAIN_EXIT_M = 1800;
/** Surface modes: draped on whatever the globe shows, or cast to bare earth. */
export const SURFACE_MODES = Object.freeze(['draped', 'terrain']);
/**
* The next surface mode for the camera. Terrain mode applies only on Google
* 3D and only at street zoom; the gap between the enter and exit heights
* stops the mode from flapping while the camera hovers at the boundary.
* @param {'draped'|'terrain'} current
* @param {{photoreal: boolean, heightM: number|null, available: boolean}} view
* @returns {'draped'|'terrain'}
*/
export function nextSurfaceMode(current, { photoreal, heightM, available }) {
if (!available || !photoreal || !Number.isFinite(heightM)) return 'draped';
if (current === 'terrain')
return heightM > SURFACE_TERRAIN_EXIT_M ? 'draped' : 'terrain';
return heightM < SURFACE_TERRAIN_ENTER_M ? 'terrain' : 'draped';
}
/**
* Split long segments so a line follows the terrain between its vertices.
* @param {Array<[number, number]>} coords [lon, lat] pairs
* @param {number} [maxStep] longest segment, in degrees
* @returns {Array<[number, number]>}
*/
export function densifyLine(coords, maxStep = GROUND_CAST_DENSIFY_DEG) {
if (!Array.isArray(coords) || coords.length < 2) return coords || [];
const out = [coords[0]];
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 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(coords[i]);
}
return out;
}
/**
* A bare-earth height cache over the terrain service.
* @param {{terrain: {resolveEllipsoidalGround: Function}, step?: number, lift?: number, maxCorners?: number}} options
*/
export function createGroundCaster({
terrain,
step = GROUND_CAST_STEP_DEG,
lift = GROUND_CAST_LIFT_M,
maxCorners = GROUND_CAST_MAX_CORNERS,
}) {
if (typeof terrain?.resolveEllipsoidalGround !== 'function')
throw new TypeError('Ground casting requires a terrain service');
/** "i,j" grid corner → ellipsoidal ground height in metres. */
const heights = new Map();
/** Prepares run one at a time, so neighbouring tiles share their corners. */
let queue = Promise.resolve();
const cornerKey = (i, j) => `${i},${j}`;
function missingCorners(points) {
const missing = new Map();
for (const point of points) {
const [lon, lat] = point;
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const i0 = Math.floor(lon / step);
const j0 = Math.floor(lat / step);
for (let di = 0; di <= 1; di++)
for (let dj = 0; dj <= 1; dj++) {
const key = cornerKey(i0 + di, j0 + dj);
if (!heights.has(key) && !missing.has(key))
missing.set(key, { i: i0 + di, j: j0 + dj });
}
}
return [...missing.entries()];
}
async function resolve(points, signal) {
if (signal?.aborted) return false;
const missing = missingCorners(points);
if (!missing.length) return true;
if (missing.length > maxCorners) return false;
const results = await terrain.resolveEllipsoidalGround(
missing.map(([, { i, j }]) => ({
lon: Number((i * step).toFixed(6)),
lat: Number((j * step).toFixed(6)),
})),
);
if (heights.size + missing.length > GROUND_CAST_CACHE_MAX) heights.clear();
let complete = true;
missing.forEach(([key], n) => {
const result = results?.[n];
if (result?.source === 'reearth' && Number.isFinite(result.ellipsoid))
heights.set(key, result.ellipsoid);
else complete = false;
});
return complete && !signal?.aborted;
}
/**
* Fetch the grid corners around the given points. Resolves true when every
* point can now be cast; false when some corners are unknown (terrain proxy
* down, too many corners, or aborted). Never rejects.
* @param {Array<[number, number]>} points [lon, lat] pairs
* @param {{signal?: AbortSignal}} [options]
*/
function prepare(points, { signal } = {}) {
const run = queue.then(() => resolve(points, signal)).catch(() => false);
queue = run;
return run;
}
/** `prepare` for lines, including the points densifying adds. */
function prepareLines(lines, options) {
return prepare(
lines.flatMap((coords) => densifyLine(coords)),
options,
);
}
/** Bare-earth ellipsoidal height at a point, from cached corners only; null when unknown. */
function groundAt(lon, lat) {
const x = lon / step;
const y = lat / step;
const i0 = Math.floor(x);
const j0 = Math.floor(y);
const h00 = heights.get(cornerKey(i0, j0));
const h10 = heights.get(cornerKey(i0 + 1, j0));
const h01 = heights.get(cornerKey(i0, j0 + 1));
const h11 = heights.get(cornerKey(i0 + 1, j0 + 1));
if (h00 === undefined || h10 === undefined) return null;
if (h01 === undefined || h11 === undefined) return null;
const fx = x - i0;
const fy = y - j0;
const south = h00 + (h10 - h00) * fx;
const north = h01 + (h11 - h01) * fx;
return south + (north - south) * fy;
}
/** Where an overlay goes: bare earth plus the lift; null when unknown. */
function heightAt(lon, lat) {
const ground = groundAt(lon, lat);
return ground === null ? null : ground + lift;
}
/**
* A line as flat [lon, lat, height, ...] degrees and metres, densified;
* null when any corner it needs is not cached yet.
* @param {Array<[number, number]>} coords
*/
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);
}
return flat;
}
return { prepare, prepareLines, groundAt, heightAt, castLine };
}
+160
View File
@@ -0,0 +1,160 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
GROUND_CAST_LIFT_M,
SURFACE_TERRAIN_ENTER_M,
SURFACE_TERRAIN_EXIT_M,
createGroundCaster,
densifyLine,
nextSurfaceMode,
} from './groundCast.js';
/** A terrain service whose ground rises 1 m per 0.001° east and 2 m per 0.001° north. */
function fakeTerrain({ source = 'reearth', fail = false } = {}) {
const calls = [];
return {
calls,
async resolveEllipsoidalGround(coords) {
calls.push(coords);
if (fail) throw new Error('proxy down');
return coords.map(({ lon, lat }) => ({
ellipsoid: 100 + (lon - 10) * 1000 + (lat - 50) * 2000,
source,
}));
},
};
}
test('surface mode: terrain only on Google 3D at street zoom, with hysteresis', () => {
const view = { photoreal: true, available: true };
assert.equal(nextSurfaceMode('draped', { ...view, heightM: 500 }), 'terrain');
assert.equal(
nextSurfaceMode('draped', {
...view,
heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2,
}),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', {
...view,
heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2,
}),
'terrain',
);
assert.equal(
nextSurfaceMode('terrain', {
...view,
heightM: SURFACE_TERRAIN_EXIT_M + 1,
}),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', { ...view, photoreal: false, heightM: 200 }),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', { ...view, available: false, heightM: 200 }),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', { ...view, heightM: null }),
'draped',
);
});
test('densifyLine splits long segments and keeps the vertices', () => {
const line = [
[10, 50],
[10.002, 50],
];
const dense = densifyLine(line, 0.0005);
assert.equal(dense.length, 5);
assert.deepEqual(dense[0], [10, 50]);
assert.deepEqual(dense.at(-1), [10.002, 50]);
assert.ok(Math.abs(dense[2][0] - 10.001) < 1e-9);
const short = [
[10, 50],
[10.0001, 50],
];
assert.deepEqual(densifyLine(short, 0.0005), short);
assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]);
});
test('the caster interpolates cached grid corners and adds the lift', async () => {
const terrain = fakeTerrain();
const caster = createGroundCaster({ terrain, step: 0.001 });
assert.equal(caster.heightAt(10.0005, 50.0005), null);
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
assert.equal(terrain.calls.length, 1);
assert.equal(terrain.calls[0].length, 4);
// Centre of the cell: mean of 100, 101, 102, 103.
const height = caster.heightAt(10.0005, 50.0005);
assert.ok(Math.abs(height - (101.5 + GROUND_CAST_LIFT_M)) < 1e-6);
assert.ok(Math.abs(caster.groundAt(10.0005, 50.0005) - 101.5) < 1e-6);
// Same cell again: nothing to fetch.
assert.equal(await caster.prepare([[10.0002, 50.0008]]), true);
assert.equal(terrain.calls.length, 1);
});
test('castLine returns lon, lat, height triples or null when a corner is missing', async () => {
const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 });
const line = [
[10.0001, 50.0001],
[10.0009, 50.0001],
];
assert.equal(caster.castLine(line), null);
await caster.prepareLines([line]);
const flat = caster.castLine(line);
assert.equal(flat.length % 3, 0);
assert.ok(flat.length >= 6);
assert.equal(flat[0], 10.0001);
assert.equal(flat[1], 50.0001);
assert.ok(flat[2] > 100 && flat[2] < 110);
});
test('a geoid fallback or a failing proxy leaves the heights unknown', async () => {
const fallback = createGroundCaster({
terrain: fakeTerrain({ source: 'geoid-fallback' }),
});
assert.equal(await fallback.prepare([[10.0005, 50.0005]]), false);
assert.equal(fallback.heightAt(10.0005, 50.0005), null);
const down = createGroundCaster({ terrain: fakeTerrain({ fail: true }) });
assert.equal(await down.prepare([[10.0005, 50.0005]]), false);
assert.equal(down.heightAt(10.0005, 50.0005), null);
});
test('too many corners are not requested, and aborted prepares do nothing', async () => {
const terrain = fakeTerrain();
const caster = createGroundCaster({ terrain, step: 0.001, maxCorners: 8 });
const wide = [];
for (let i = 0; i < 10; i++) wide.push([10 + i * 0.001 + 0.0005, 50.0005]);
assert.equal(await caster.prepare(wide), false);
assert.equal(terrain.calls.length, 0);
const controller = new AbortController();
controller.abort();
assert.equal(
await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal }),
false,
);
assert.equal(terrain.calls.length, 0);
});
test('prepares run one at a time so neighbours share corners', async () => {
const terrain = fakeTerrain();
const caster = createGroundCaster({ terrain, step: 0.001 });
const [a, b] = await Promise.all([
caster.prepare([[10.0005, 50.0005]]),
caster.prepare([[10.0015, 50.0005]]),
]);
assert.equal(a, true);
assert.equal(b, true);
// The second cell shares its west corners with the first.
assert.equal(terrain.calls[1].length, 2);
});
test('the caster needs a terrain service', () => {
assert.throws(() => createGroundCaster({ terrain: null }), TypeError);
});
+91 -2
View File
@@ -9,7 +9,11 @@ import { requiresKeyIdFor, validateProviders } from './registry.js';
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { decodeParams, encodeParams } from './params.js';
import { composeUIState, summarizeCoverage } from './uiState.js';
import { viewCentre } from './view.js';
import { cameraHeightAboveGround, viewCentre } from './view.js';
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
/** Above this camera height the ground under the camera is not worth fetching. */
const SURFACE_WARM_BELOW_M = 6000;
import {
FOLLOW_MAP_STACK_ID,
NEAREST_RADIUS_M,
@@ -35,6 +39,9 @@ export function createStreetLevelLayer({
const state = createState({ services });
const parts = {};
const context = { state, parts };
parts.groundCaster = services.terrain?.resolveEllipsoidalGround
? createGroundCaster({ terrain: services.terrain })
: null;
parts.credits = createCredits();
parts.marker = createMarker(context);
parts.follow = createCameraFollow(context);
@@ -83,6 +90,10 @@ export function createStreetLevelLayer({
getViewer: () => state.viewer,
getFilter: () => resolveFilter(state.filter),
isActive: () => state.enabled && entry.on,
/** Bare-earth heights (see groundCast.js), or null without a terrain service. */
groundCaster: parts.groundCaster,
/** 'terrain' when overlays should sit on the bare earth, else 'draped'. */
getSurface: () => state.surface,
notify,
actions: {
openImage: (imageId) => openImage(entry.def.id, imageId),
@@ -112,6 +123,75 @@ export function createStreetLevelLayer({
notify();
}
/**
* Camera height above the bare earth under it, for the surface mode. The
* ground comes from the caster's coarse grid; a cold cell is fetched once
* and the mode checked again when it lands.
*/
function cameraHeightForSurface() {
const carto = state.viewer?.camera?.positionCartographic;
if (!carto) return null;
const fallback = cameraHeightAboveGround(state.viewer);
if (!(fallback < SURFACE_WARM_BELOW_M)) return fallback;
const lon = (carto.longitude * 180) / Math.PI;
const lat = (carto.latitude * 180) / Math.PI;
const ground = parts.groundCaster.heightAt(lon, lat);
if (ground !== null) return carto.height - ground;
parts.groundCaster.prepare([[lon, lat]]).then((ready) => {
if (ready) scheduleSurfaceSync();
});
return fallback;
}
/**
* On Google 3D at street zoom, overlays are cast to the bare earth; draped
* lines would land on roofs and trees. Elsewhere they stay draped.
*/
function syncSurface() {
const available = Boolean(parts.groundCaster) && state.enabled;
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
const mode = nextSurfaceMode(state.surface, {
available,
photoreal,
heightM: available && photoreal ? cameraHeightForSurface() : null,
});
if (mode === state.surface) return;
state.surface = mode;
parts.marker.setSurface(mode);
for (const entry of state.providers.values())
entry.instance.setSurface?.(mode);
notify();
}
let surfaceTimer = null;
function scheduleSurfaceSync() {
clearTimeout(surfaceTimer);
surfaceTimer = setTimeout(syncSurface, 150);
}
let removeSurfaceListeners = null;
function watchSurface(viewer) {
removeSurfaceListeners?.();
const camera = viewer?.camera;
if (!camera?.changed || !camera?.moveEnd) {
removeSurfaceListeners = null;
return;
}
const removeChanged = camera.changed.addEventListener(scheduleSurfaceSync);
const removeEnd = camera.moveEnd.addEventListener(scheduleSurfaceSync);
removeSurfaceListeners = () => {
removeChanged();
removeEnd();
};
}
function unwatchSurface() {
removeSurfaceListeners?.();
removeSurfaceListeners = null;
clearTimeout(surfaceTimer);
surfaceTimer = null;
}
const activeEntries = () =>
[...state.providers.values()].filter((entry) => entry.on);
@@ -213,6 +293,7 @@ export function createStreetLevelLayer({
providers: providerSnapshots(),
street,
sequence: sequenceSnapshot(),
surface: state.surface,
});
}
@@ -247,13 +328,17 @@ export function createStreetLevelLayer({
parts.marker.setVisible(true);
parts.selection.install(viewer);
for (const entry of activeEntries()) activate(entry);
watchSurface(viewer);
syncSurface();
notify();
},
disable() {
state.enabled = false;
unwatchSurface();
parts.viewerHost.unmount();
for (const entry of state.providers.values()) entry.instance.deactivate();
syncSurface();
parts.credits.hideAll(state.viewer);
parts.selection.uninstall();
parts.marker.setVisible(false);
@@ -300,8 +385,12 @@ export function createStreetLevelLayer({
unsubscribeMapStack?.();
mapStack = controller || null;
unsubscribeMapStack =
mapStack?.subscribe?.(() => syncFollowAvailability()) || null;
mapStack?.subscribe?.(() => {
syncFollowAvailability();
syncSurface();
}) || null;
syncFollowAvailability();
syncSurface();
},
/** Share-link and stored state: provider switches plus the filter. */
+48 -6
View File
@@ -4,9 +4,14 @@ import { COLORS, POSITION_PICK_ID } from './policy.js';
const SPRITE_ID = 'street-level:marker';
/** The on-globe marker for the image the viewer currently shows. */
export function createMarker({ state }) {
/**
* The on-globe marker for the image the viewer currently shows. In terrain
* mode (Google 3D at street zoom) it stands on the bare earth instead of
* clamping to the top of the photoreal mesh.
*/
export function createMarker({ state, parts }) {
const { render, sprites } = state.services;
let last = null;
function requestRender() {
render?.governorRequestRender?.('street-level-marker');
@@ -21,17 +26,39 @@ export function createMarker({ state }) {
sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection);
}
/** Terrain height for the position, or null to clamp; fetches a missing cell. */
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;
}
/** Move (or create) the marker; a null position removes it. */
function set(position, bearing) {
const collection = state.marker.collection;
if (!collection) return;
last = position ? { position, bearing } : null;
if (!position) {
collection.removeAll();
state.marker.billboard = null;
requestRender();
return;
}
const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat);
const height = castHeight(position);
const cartesian = Cesium.Cartesian3.fromDegrees(
position.lon,
position.lat,
height ?? 0,
);
const heightReference =
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE;
if (!state.marker.billboard) {
state.marker.billboard = collection.add({
id: POSITION_PICK_ID,
@@ -41,11 +68,14 @@ export function createMarker({ state }) {
width: 40,
height: 40,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
heightReference,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
});
} else state.marker.billboard.position = cartesian;
} else {
state.marker.billboard.heightReference = heightReference;
state.marker.billboard.position = cartesian;
}
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
requestRender();
}
@@ -64,5 +94,17 @@ export function createMarker({ state }) {
state.marker.billboard = null;
}
return { ensure, set, clear: () => set(null), setVisible, destroy };
/** Re-place the marker after the surface mode changes. */
function setSurface() {
if (last) set(last.position, last.bearing);
}
return {
ensure,
set,
clear: () => set(null),
setVisible,
setSurface,
destroy,
};
}
@@ -19,6 +19,8 @@ import {
PICK_PREFIX,
} from './policy.js';
/** Draped lines stay at most this long while a tile's cast lines build. */
const SWAP_MAX_WAIT_MS = 4000;
/** Old-zoom tiles are kept at most this long after a zoom change. */
const STALE_TILE_MAX_MS = 6000;
/**
@@ -47,6 +49,11 @@ function sequenceColor(sequence, { selected = false, now = Date.now() } = {}) {
* Camera-driven coverage: z0–5 `overview` points from orbit down to 60 km,
* then z11–14 sequence polylines clamped to terrain and 3D tiles. Decoded
* tiles are kept so the imagery filter can rebuild without refetching.
*
* In the core's terrain surface mode (Google 3D at street zoom) a tile's
* lines are cast to the bare earth instead: draped lines would land on roofs
* and tree tops. A tile is drawn draped first and swapped for its cast lines
* once the terrain heights are in, so coverage never waits on the terrain.
*/
export function createCoverage({ state, source }) {
const { render } = state.services;
@@ -68,51 +75,156 @@ export function createCoverage({ state, source }) {
return state.filter;
}
/** One or more ground primitives for a tile's sequences, in draw batches. */
function terrainMode() {
return (
state.context.getSurface?.() === 'terrain' &&
Boolean(state.context.groundCaster)
);
}
function sequenceInstance(sequence, geometry, now) {
return new Cesium.GeometryInstance({
geometry,
id: `${PICK_PREFIX.sequence}${sequence.id}`,
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
sequenceColor(sequence, {
now,
selected: sequence.id === state.sequence.selectedId,
}),
),
},
});
}
/**
* Primitives for a tile's sequences, in draw batches: ground primitives
* draped on the globe, plus (in terrain mode) plain polylines at the cast
* bare-earth heights for every sequence whose heights are cached.
*/
function buildSequencePrimitives(sequences) {
const now = Date.now();
const instances = [];
const ground = terrainMode() ? state.context.groundCaster : null;
const draped = [];
const cast = [];
for (const sequence of sequences) {
if (!passesImageryFilter(sequence, filter())) continue;
const flat = [];
for (const [lon, lat] of sequence.coordinates) flat.push(lon, lat);
const flat = ground?.castLine(sequence.coordinates);
let positions;
try {
positions = Cesium.Cartesian3.fromDegreesArray(flat);
positions = flat
? Cesium.Cartesian3.fromDegreesArrayHeights(flat)
: Cesium.Cartesian3.fromDegreesArray(sequence.coordinates.flat());
} catch {
continue;
}
if (positions.length < 2) continue;
instances.push(
new Cesium.GeometryInstance({
geometry: new Cesium.GroundPolylineGeometry({
positions,
width: COVERAGE_LINE_WIDTH_PX,
}),
id: `${PICK_PREFIX.sequence}${sequence.id}`,
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
sequenceColor(sequence, {
now,
selected: sequence.id === state.sequence.selectedId,
}),
),
},
}),
);
if (flat)
cast.push(
sequenceInstance(
sequence,
new Cesium.PolylineGeometry({
positions,
width: COVERAGE_LINE_WIDTH_PX,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
arcType: Cesium.ArcType.NONE,
}),
now,
),
);
else
draped.push(
sequenceInstance(
sequence,
new Cesium.GroundPolylineGeometry({
positions,
width: COVERAGE_LINE_WIDTH_PX,
}),
now,
),
);
}
const primitives = [];
for (let i = 0; i < instances.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push(
new Cesium.GroundPolylinePrimitive({
geometryInstances: instances.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push({
onGround: true,
primitive: new Cesium.GroundPolylinePrimitive({
geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
appearance: new Cesium.PolylineColorAppearance(),
classificationType: Cesium.ClassificationType.BOTH,
asynchronous: true,
allowPicking: true,
}),
});
for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push({
onGround: false,
primitive: new Cesium.Primitive({
geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
asynchronous: true,
allowPicking: true,
}),
});
return { primitives, count: draped.length + cast.length, draped };
}
/**
* Fetch the terrain heights a tile's lines need, then redraw the tile cast.
* Runs once per tile entry; unresolved lines stay draped.
*/
function castTile(entry) {
if (entry.castRequested || !terrainMode()) return;
entry.castRequested = true;
entry.castAbort = new AbortController();
const { signal } = entry.castAbort;
const lines = entry.sequenceList
.filter((sequence) => passesImageryFilter(sequence, filter()))
.map((sequence) => sequence.coordinates);
state.context.groundCaster.prepareLines(lines, { signal }).then(() => {
const attached = [...state.coverage.tiles.values()].includes(entry);
if (signal.aborted || !attached || !terrainMode()) return;
// Keep the draped lines until the cast ones are built, so nothing blinks.
const previous = entry.primitives;
entry.primitives = [];
attachPrimitive(entry);
removeWhenReady(entry, previous);
});
}
/**
* 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.
*/
function removeWhenReady(entry, old) {
const scene = state.viewer?.scene;
finishSwap(entry);
if (!scene?.postRender) {
removePrimitives(old);
return;
}
const fresh = entry.primitives;
const started = Date.now();
const stop = scene.postRender.addEventListener(() => {
const ready = fresh.every(
({ primitive }) => primitive.ready || primitive.isDestroyed?.(),
);
return { primitives, count: instances.length };
if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) {
requestRender();
return;
}
finishSwap(entry);
requestRender();
});
entry.swap = { old, stop };
requestRender();
}
function finishSwap(entry) {
if (!entry.swap) return;
entry.swap.stop();
removePrimitives(entry.swap.old);
entry.swap = null;
}
function buildOverviewCollection(points) {
@@ -141,10 +253,14 @@ export function createCoverage({ state, source }) {
const scene = state.viewer?.scene;
if (!scene) return;
if (entry.kind === 'sequence') {
const { primitives, count } = buildSequencePrimitives(entry.sequenceList);
const { primitives, count, draped } = buildSequencePrimitives(
entry.sequenceList,
);
entry.primitives = primitives;
entry.count = count;
for (const primitive of primitives) scene.groundPrimitives.add(primitive);
for (const { primitive, onGround } of primitives)
(onGround ? scene.groundPrimitives : scene.primitives).add(primitive);
if (draped.length) castTile(entry);
} else {
const { collection, count } = buildOverviewCollection(entry.points);
entry.primitive = collection;
@@ -153,15 +269,23 @@ export function createCoverage({ state, source }) {
}
}
function detachPrimitive(entry) {
function removePrimitives(list) {
const scene = state.viewer?.scene;
for (const primitive of entry.primitives || []) {
for (const { primitive, onGround } of list || []) {
try {
scene?.groundPrimitives?.remove(primitive);
(onGround ? scene?.groundPrimitives : scene?.primitives)?.remove(
primitive,
);
} catch {
/* already gone */
}
}
}
function detachPrimitive(entry) {
const scene = state.viewer?.scene;
finishSwap(entry);
removePrimitives(entry.primitives);
entry.primitives = [];
if (entry.primitive) {
try {
@@ -173,9 +297,16 @@ export function createCoverage({ state, source }) {
}
}
/** Stop a tile's pending terrain lookup (tile dropped or retired). */
function cancelCast(entry) {
entry.castAbort?.abort();
entry.castAbort = null;
}
function removeTile(key) {
const entry = state.coverage.tiles.get(key);
if (!entry) return;
cancelCast(entry);
detachPrimitive(entry);
state.coverage.tiles.delete(key);
}
@@ -254,6 +385,7 @@ export function createCoverage({ state, source }) {
state.coverage.pending.clear();
state.coverage.loading = 0;
for (const [key, entry] of state.coverage.tiles) {
cancelCast(entry);
const previous = state.coverage.stale.get(key);
if (previous) detachPrimitive(previous);
state.coverage.stale.set(key, entry);
@@ -350,6 +482,8 @@ export function createCoverage({ state, source }) {
function rebuild() {
purgeStale();
for (const entry of state.coverage.tiles.values()) {
cancelCast(entry);
entry.castRequested = false;
detachPrimitive(entry);
attachPrimitive(entry);
}
@@ -357,6 +491,12 @@ export function createCoverage({ state, source }) {
notify();
}
/** Redraw loaded tiles draped or cast after the core's surface mode changes. */
function setSurface() {
if (!state.context.isActive()) return;
rebuild();
}
/** Look a sequence up across loaded tiles. */
function findSequence(id) {
for (const entry of state.coverage.tiles.values()) {
@@ -372,7 +512,7 @@ export function createCoverage({ state, source }) {
for (const entry of state.coverage.tiles.values()) {
const sequence = entry.sequences.get(id);
if (!sequence) continue;
for (const primitive of entry.primitives || []) {
for (const { primitive } of entry.primitives || []) {
if (!primitive.ready) continue;
try {
const attributes =
@@ -402,6 +542,7 @@ export function createCoverage({ state, source }) {
refresh,
clear,
rebuild,
setSurface,
findSequence,
recolorSequence,
sequenceCount,
@@ -151,6 +151,12 @@ export function createMapillaryProvider({ source }) {
parts.sequences.rerender();
},
/** Core surface mode changed: redraw lines and cones draped or cast. */
setSurface() {
parts.coverage.setSurface();
parts.sequences.rerender();
},
coverageStats() {
return {
count: parts.coverage.sequenceCount(),
@@ -77,29 +77,59 @@ export function createSequences({ state, source, parts }) {
state.sequence.images = [];
}
/**
* 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.
*/
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),
);
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) {
const collection = state.sequence.collection;
if (!collection) return;
collection.removeAll();
const heights = coneHeights(images);
const cone = imageConeGlyph({ size: 32, color: COLORS.image });
const ring = imageConeGlyph({ size: 32, color: COLORS.pano, pano: true });
for (const image of images) {
if (!passesImageryFilter(image, state.filter)) continue;
images.forEach((image, index) => {
if (!passesImageryFilter(image, state.filter)) return;
const height = heights?.[index] ?? null;
collection.add({
id: `${PICK_PREFIX.image}${image.id}`,
position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat),
position: Cesium.Cartesian3.fromDegrees(
image.lon,
image.lat,
height ?? 0,
),
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: Cesium.HeightReference.CLAMP_TO_GROUND,
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),
});
}
});
requestRender();
}
+11 -1
View File
@@ -15,10 +15,12 @@
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
* @property {object} services Scene services: picking, input, render, sprites, ground.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain.
* @property {() => object|null} getViewer The Cesium viewer once the layer is initialised.
* @property {() => {pano: string, sinceMs: number|null}} getFilter The resolved imagery filter.
* @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 {() => void} notify Ask the core to publish a new UI snapshot.
* @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions
*
@@ -30,6 +32,7 @@
* @property {(viewer: object) => void} destroy
* @property {() => void} refreshCoverage
* @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter
* @property {(mode: 'draped'|'terrain') => void} [setSurface] Redraw coverage and cones for the new surface mode.
* @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats
* @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix.
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
@@ -38,6 +41,13 @@
* @property {(point: {lat: number, lon: number}) => Promise<string|null>} nearestImage
* @property {ViewerAdapter} viewer
*
* @typedef {object} GroundCaster
* @property {(points: Array<[number, number]>, options?: {signal?: AbortSignal}) => Promise<boolean>} prepare Fetch the heights around [lon, lat] points; true when all are known.
* @property {(lines: Array<Array<[number, number]>>, options?: {signal?: AbortSignal}) => Promise<boolean>} prepareLines
* @property {(lon: number, lat: number) => number|null} groundAt Cached bare-earth ellipsoidal height.
* @property {(lon: number, lat: number) => number|null} heightAt groundAt plus the overlay lift.
* @property {(coords: Array<[number, number]>) => Array<number>|null} castLine Densified [lon, lat, height, ...] or null.
*
* @typedef {object} ViewerAdapter
* @property {(host: HTMLElement) => Promise<void>} mount Idempotent; may lazy-load a library.
* @property {(imageId: string) => Promise<void>} open Resolves once the image is on screen and a pose was emitted.
+3
View File
@@ -40,6 +40,9 @@ export function createState({ services }) {
renderMode: 'letterbox',
},
/** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
surface: 'draped',
marker: { collection: null, billboard: null },
clickHandler: null,
};
+2
View File
@@ -48,6 +48,7 @@ export function composeUIState({
providers,
street,
sequence,
surface = 'draped',
}) {
const active = providers.filter((p) => p.on);
const { keyRequired, ...coverage } = summarizeCoverage(providers);
@@ -73,5 +74,6 @@ export function composeUIState({
legend,
sequence: { ...sequence },
street: { ...street },
surface,
};
}
+12
View File
@@ -120,3 +120,15 @@ test('summarizeCoverage is what getStats reports, without building a snapshot',
);
assert.equal(summarizeCoverage([]).keyRequired, false);
});
test('the surface mode defaults to draped and passes through', () => {
assert.equal(
composeUIState({ ...base, providers: [provider()] }).surface,
'draped',
);
assert.equal(
composeUIState({ ...base, providers: [provider()], surface: 'terrain' })
.surface,
'terrain',
);
});