mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
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:
1 parent
c7dc3146f2
commit
d3d3898dbb
17 files changed
+832
-49
No files matched your search
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user