mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
The raycast challenge on #768 showed bare-earth casting misplacing roads that are not at grade: lines floated over freeways in trenches and steep streets (rendered line pixels floating: Austin 8.5%, SF 27.1%, LA 43.8%), because a ~111 m terrain grid cannot see a 30 m-wide cut or a slope between its corners. meshSampler.js 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; misses are retried, not latched. groundCast.refineHeights blends it in, per point along a line: - mesh up to 3 m above bare earth is the road: follow it, 1 m above; - higher mesh (canopy, deck, roof): carry the road's offset from bare earth across from the road samples on both sides (up to 8 points); - otherwise bare earth + 2 m, as before. Lines, cones and the marker use it; tiles touched by new samples are redrawn one per idle slice with the no-blink swap. Rendered line pixels on the surface / floating, before -> after: Austin 90.0 -> 95.3% / 8.5 -> 0.7%; SF 69.0 -> 86.4% / 27.1 -> 9.1%; LA 52.3 -> 91.0% / 43.8 -> 4.0%; Innsbruck 95.0 -> 95.7% / 0.2 -> 0.2%; Sacramento 80.7 -> 79.3% / 3.8 -> 2.7%; buried lines stay under 1%. Cones floating in SF 44 -> 5%, buried at LA 59 -> 28%. Main-thread long tasks added during refinement: at most 134 ms (Austin, densest tile). Roads on elevated decks are still drawn at ground level under the deck. Unit tests cover the blend (trench, slope, canopy bridge, deck, bad probe), the finer cast with meshAt, and the sampler (once per cell, nearest first, overlays excluded, far cells skipped, misses not latched, idle when off). Co-Authored-By: Claude Opus 5.5 <[email protected]>
264 lines
10 KiB
JavaScript
264 lines
10 KiB
JavaScript
/**
|
|
* 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;
|
|
|
|
/**
|
|
* 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<number>}
|
|
*/
|
|
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. */
|
|
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. 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, { 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;
|
|
}
|
|
|
|
return { prepare, prepareLines, groundAt, heightAt, castLine };
|
|
}
|