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

+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. */