mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
- Photo framing and FOLLOW claim the camera through the application's
navigation, injected with attachNavigation({ run, subscribeHandoff }):
aircraft and satellite tracking is released first, the cockpit refuses,
and FOLLOW stops when another feature takes the camera. No UI imports
in the layer.
- The mesh sampler follows the mesh floor's rules: it waits for the
visible tileset to finish streaming (visibleTilesetLoaded, now exposed
by the mesh floor service), needs a real bare-earth prior and keeps to
the shared meshFloorSampleWithinPrior window, which cameraFollow now
uses too. A cell is probed again once the camera is half as far from
it (down to 40 m), so a coarse early sample is replaced and redrawn.
- The MCP globe panel's CSP allows the Mapillary Graph API and its image
CDN (https://*.xx.fbcdn.net).
- The tile proxy reads bodies with the shared readResponseBytesCapped.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
374 lines
12 KiB
JavaScript
374 lines
12 KiB
JavaScript
import * as Cesium from 'cesium';
|
||
import { metresBetween, whenIdle } from './view.js';
|
||
|
||
/**
|
||
* Google 3D surface heights for ground casting (groundCast.js refineHeights):
|
||
* one cached `scene.sampleHeight` probe per ~11 m cell. Each probe renders a
|
||
* pick pass (1–3 ms), so probes run in budgeted idle slices, nearest the
|
||
* camera first and only within MESH_SAMPLE_RADIUS_M. Like the application's
|
||
* mesh floor, a probe waits for the visible tileset to finish streaming and a
|
||
* sample counts only within the shared window around a real bare-earth height;
|
||
* anything else is a miss, retried later. Probes hit 3D tilesets only.
|
||
*/
|
||
|
||
/** 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 per idle slice, taking requests in and ranking included. */
|
||
export const MESH_SAMPLE_BUDGET_MS = 6;
|
||
/** The queue is re-ranked once the camera has moved this far, in metres. */
|
||
export const MESH_RERANK_M = 100;
|
||
/** A cell whose probe missed is tried again after this long. */
|
||
export const MESH_MISS_RETRY_MS = 8000;
|
||
/** While the visible tileset is streaming, the next probe waits this long. */
|
||
export const MESH_STREAMING_WAIT_MS = 500;
|
||
/**
|
||
* A sampled cell is probed again once the camera is half as far from it as at
|
||
* its last probe (finer tiles have loaded by then), down to this distance:
|
||
* at most a handful of probes per cell.
|
||
*/
|
||
export const MESH_REFRESH_MIN_M = 40;
|
||
/** A refreshed sample that moved less than this is not redrawn. */
|
||
const MESH_REFRESH_CHANGE_M = 0.5;
|
||
/** Cached cells (and remembered misses) before they are 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);
|
||
|
||
/** Numeric key of the cell holding a point (exact within 2^53; no per-point string). */
|
||
export const meshCellKey = (lon, lat) =>
|
||
(cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000;
|
||
|
||
/** Add a queued cell to a min-heap on `distance`. */
|
||
function heapPush(heap, cell) {
|
||
let i = heap.push(cell) - 1;
|
||
while (i > 0) {
|
||
const parent = (i - 1) >> 1;
|
||
if (heap[parent].distance <= cell.distance) break;
|
||
heap[i] = heap[parent];
|
||
i = parent;
|
||
}
|
||
heap[i] = cell;
|
||
}
|
||
|
||
/** Restore the heap below `i` after its cell was replaced. */
|
||
function siftDown(heap, i) {
|
||
const cell = heap[i];
|
||
const n = heap.length;
|
||
for (;;) {
|
||
let child = 2 * i + 1;
|
||
if (child >= n) break;
|
||
if (child + 1 < n && heap[child + 1].distance < heap[child].distance)
|
||
child++;
|
||
if (heap[child].distance >= cell.distance) break;
|
||
heap[i] = heap[child];
|
||
i = child;
|
||
}
|
||
heap[i] = cell;
|
||
}
|
||
|
||
function heapPop(heap) {
|
||
const top = heap[0];
|
||
const last = heap.pop();
|
||
if (heap.length) {
|
||
heap[0] = last;
|
||
siftDown(heap, 0);
|
||
}
|
||
return top;
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/**
|
||
* `groundAt(lon, lat)` is the bare-earth height or null, `withinPrior(height,
|
||
* prior)` the application's mesh window, and `tilesReady(scene)` whether the
|
||
* visible tileset has finished streaming.
|
||
* @param {{getViewer: () => object|null, groundAt?: Function, withinPrior?: Function, tilesReady?: Function, budgetMs?: number, radiusM?: number, cacheMax?: number, now?: () => number}} options
|
||
*/
|
||
export function createMeshSampler({
|
||
getViewer,
|
||
groundAt = null,
|
||
withinPrior = null,
|
||
tilesReady = null,
|
||
budgetMs = MESH_SAMPLE_BUDGET_MS,
|
||
radiusM = MESH_SAMPLE_RADIUS_M,
|
||
cacheMax = MESH_CACHE_MAX,
|
||
now = () => performance.now(),
|
||
}) {
|
||
/** cell key → sampled mesh height (ellipsoidal metres). */
|
||
const heights = new Map();
|
||
/** cell key → camera distance (m) at the cell's last probe. */
|
||
const probedFrom = new Map();
|
||
/** cell key → retry time for probes that hit nothing; bounded like the height cache. */
|
||
const misses = new Map();
|
||
/** cell key → queued cell, so a cell is queued once. */
|
||
const wanted = new Map();
|
||
/** Queued {key, lon, lat, distance} cells, a min-heap on distance from `rankedFrom`. */
|
||
let queue = [];
|
||
/** Requests not yet taken in: {points, next, centre} (centre: the camera then). */
|
||
let intake = [];
|
||
/** Where the camera was when the queue was ranked. */
|
||
let rankedFrom = null;
|
||
/** Running estimate of one probe's cost, so a slice stops before overrunning. */
|
||
let probeMs = 1;
|
||
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(meshCellKey(lon, lat));
|
||
}
|
||
|
||
/** The point under the camera, with the camera's height, or null. */
|
||
function cameraCentre() {
|
||
const carto = getViewer()?.camera?.positionCartographic;
|
||
if (!carto) return null;
|
||
return {
|
||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||
height: carto.height,
|
||
};
|
||
}
|
||
|
||
/** Camera distance (m) to a cell at `height`, from its ground distance. */
|
||
function cameraDistance(ground, centre, height) {
|
||
const above = (centre.height ?? 0) - (height ?? 0);
|
||
return Number.isFinite(above)
|
||
? Math.hypot(ground, Math.max(0, above))
|
||
: ground;
|
||
}
|
||
|
||
/** A sampled cell is probed again once the camera is half as far as before. */
|
||
function dueForRefresh(key, distance) {
|
||
const last = probedFrom.get(key);
|
||
return last > MESH_REFRESH_MIN_M && distance < last / 2;
|
||
}
|
||
|
||
/** A probe result the application would accept, or undefined. */
|
||
function validated(height, lon, lat) {
|
||
if (!Number.isFinite(height)) return undefined;
|
||
if (!groundAt) return height;
|
||
const prior = groundAt(lon, lat);
|
||
if (!Number.isFinite(prior)) return undefined;
|
||
return !withinPrior || withinPrior(height, prior) ? height : undefined;
|
||
}
|
||
|
||
/**
|
||
* Ask for the cells under [lon, lat] points; the list is read later, so do
|
||
* not mutate it. Out-of-range cells are dropped; callers ask again on move.
|
||
*/
|
||
function request(points) {
|
||
if (!enabled || !points?.length) return;
|
||
const centre = cameraCentre();
|
||
if (!centre) return;
|
||
intake.push({ points, next: 0, centre });
|
||
schedule();
|
||
}
|
||
|
||
/** Queue requested cells, oldest request first, until the slice reaches `until`. */
|
||
function takeIn(until) {
|
||
const time = Date.now();
|
||
while (intake.length) {
|
||
const pending = intake[0];
|
||
const { points, centre } = pending;
|
||
while (pending.next < points.length) {
|
||
if ((pending.next & 255) === 255 && now() > until) return;
|
||
const [lon, lat] = points[pending.next++];
|
||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||
const key = meshCellKey(lon, lat);
|
||
if (wanted.has(key)) continue;
|
||
const cell = {
|
||
key,
|
||
lon: cellOf(lon) * MESH_CELL_DEG,
|
||
lat: cellOf(lat) * MESH_CELL_DEG,
|
||
distance: 0,
|
||
};
|
||
const ground = metresBetween(cell, centre);
|
||
if (ground > radiusM) continue;
|
||
if (
|
||
heights.has(key) &&
|
||
!dueForRefresh(key, cameraDistance(ground, centre, heights.get(key)))
|
||
)
|
||
continue;
|
||
const retryAt = misses.get(key);
|
||
if (retryAt !== undefined) {
|
||
if (retryAt > time) continue;
|
||
misses.delete(key);
|
||
}
|
||
cell.distance = metresBetween(cell, rankedFrom);
|
||
wanted.set(key, cell);
|
||
heapPush(queue, cell);
|
||
}
|
||
intake.shift();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Rank the queue from where the camera is now, dropping cells it has left
|
||
* out of range: one linear heapify, run only after the camera has moved.
|
||
*/
|
||
function rerank(centre) {
|
||
rankedFrom = centre;
|
||
const kept = [];
|
||
for (const cell of queue) {
|
||
cell.distance = metresBetween(cell, centre);
|
||
if (cell.distance > radiusM) wanted.delete(cell.key);
|
||
else kept.push(cell);
|
||
}
|
||
queue = kept;
|
||
for (let i = (queue.length >> 1) - 1; i >= 0; i--) siftDown(queue, i);
|
||
}
|
||
|
||
function clearQueue() {
|
||
intake = [];
|
||
wanted.clear();
|
||
queue = [];
|
||
rankedFrom = null;
|
||
}
|
||
|
||
function schedule() {
|
||
if (running || !enabled || !(queue.length || intake.length)) return;
|
||
running = true;
|
||
whenIdle(step, 300);
|
||
}
|
||
|
||
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;
|
||
// The slice starts now: taking requests in and ranking count too.
|
||
const started = now();
|
||
const until =
|
||
started +
|
||
Math.min(budgetMs, Math.max(1, deadline?.timeRemaining?.() ?? budgetMs));
|
||
const scene = getViewer()?.scene;
|
||
const centre = cameraCentre();
|
||
if (!enabled || !scene?.sampleHeightSupported || !centre) return;
|
||
// Mid-stream tiles answer with coarse heights: wait for the stream.
|
||
if (tilesReady && !tilesReady(scene)) {
|
||
running = true;
|
||
setTimeout(() => {
|
||
running = false;
|
||
schedule();
|
||
}, MESH_STREAMING_WAIT_MS);
|
||
return;
|
||
}
|
||
// Nearest first, ranked from where the camera was; out-of-range cells
|
||
// are dropped (they stay bare earth).
|
||
let worked = false;
|
||
if (!rankedFrom || metresBetween(rankedFrom, centre) > MESH_RERANK_M) {
|
||
rerank(centre);
|
||
worked = true;
|
||
}
|
||
if (intake.length) {
|
||
// Leave room for a probe, so a stream of requests cannot starve them.
|
||
takeIn(until - probeMs);
|
||
worked = true;
|
||
}
|
||
const exclude = overlays(scene);
|
||
let probed = 0;
|
||
while (queue.length) {
|
||
// Stop before a probe would overrun the slice. A slice that did
|
||
// nothing else always probes once, so the queue drains.
|
||
if ((probed || worked) && now() + probeMs > until) break;
|
||
const { key, lon, lat } = heapPop(queue);
|
||
wanted.delete(key);
|
||
// Ranked from up to MESH_RERANK_M away: check the range from here.
|
||
const ground = metresBetween({ lon, lat }, centre);
|
||
if (ground > radiusM) continue;
|
||
probed++;
|
||
const probeStarted = now();
|
||
let height;
|
||
try {
|
||
height = scene.sampleHeight(
|
||
Cesium.Cartographic.fromDegrees(lon, lat),
|
||
exclude,
|
||
);
|
||
} catch {
|
||
height = undefined;
|
||
}
|
||
probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25;
|
||
height = validated(height, lon, lat);
|
||
const previous = heights.get(key);
|
||
if (height === undefined) {
|
||
// A failed refresh keeps the old sample, and waits for a closer camera.
|
||
if (previous !== undefined)
|
||
probedFrom.set(key, cameraDistance(ground, centre, previous));
|
||
else {
|
||
if (misses.size >= cacheMax) misses.clear();
|
||
misses.set(key, Date.now() + MESH_MISS_RETRY_MS);
|
||
}
|
||
continue;
|
||
}
|
||
if (previous === undefined && heights.size >= cacheMax) forget();
|
||
misses.delete(key);
|
||
heights.set(key, height);
|
||
probedFrom.set(key, cameraDistance(ground, centre, height));
|
||
if (
|
||
previous === undefined ||
|
||
Math.abs(height - previous) >= MESH_REFRESH_CHANGE_M
|
||
)
|
||
fresh.push([lon, lat]);
|
||
}
|
||
if (fresh.length && !notifyTimer)
|
||
notifyTimer = setTimeout(emit, MESH_NOTIFY_MS);
|
||
schedule();
|
||
}
|
||
|
||
function forget() {
|
||
heights.clear();
|
||
probedFrom.clear();
|
||
misses.clear();
|
||
}
|
||
|
||
/** Probe only while overlays are cast (Google 3D at street zoom). */
|
||
function setEnabled(on) {
|
||
enabled = on === true;
|
||
if (!enabled) clearQueue();
|
||
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;
|
||
forget();
|
||
clearQueue();
|
||
listeners.clear();
|
||
clearTimeout(notifyTimer);
|
||
notifyTimer = null;
|
||
}
|
||
|
||
return { meshAt, request, setEnabled, onSampled, destroy };
|
||
}
|