mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
fix(street-level): budgeted mesh sampling, in-place cones, date line, horizon
From the IC8 review of #768, each with tests that fail before: - P1: a mesh batch no longer rebuilds the selected sequence's cones. The drawn cones are indexed by mesh cell; a batch that touches none of them is ignored and the affected cones are moved in place (no removeAll/add). - P2: mesh sampling keeps to its 6 ms slices. Requests are taken in inside the budget, only cells within range of the camera are queued, the queue is a persistent min-heap re-ranked in one pass only after a 100 m camera move, and the deadline starts before any work. Coverage keeps a bounding box per line and densifies a line only once it comes within range. On a synthetic dense tile (600 x 40 points): slice median ~10 ms -> 5 ms, max 18-30 ms -> 6.7 ms, refresh cost 11-24 ms -> 1.5-3 ms. - P2: image cones and the photo marker are horizon-culled like the overview dots (one createHorizonCull in view.js, shared by all three). - P2: a selection change recolours the previous zoom's lines still on screen and the old lines a cast swap keeps showing. - P2: distances (metresBetween), line densification and ground-corner requests go the short way round +-180 deg (wrapLon in tileMath.js), so nearest ordering, cone thinning, mesh range and cast lines are right across the date line. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
09fc10756c
commit
bd97d38a99
15 files changed
+1085
-164
No files matched your search
@@ -2,6 +2,7 @@ import * as Cesium from 'cesium';
|
||||
import { decodeCoverageTile } from './decode.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import {
|
||||
createHorizonCull,
|
||||
groundUnderCamera,
|
||||
metresBetween,
|
||||
viewFocus,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
whenIdle,
|
||||
} from '../../view.js';
|
||||
import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js';
|
||||
import { MESH_CELL_DEG } from '../../meshSampler.js';
|
||||
import { MESH_SAMPLE_RADIUS_M } from '../../meshSampler.js';
|
||||
import {
|
||||
coverageZoomForHeight,
|
||||
overviewZoomForHeight,
|
||||
@@ -256,27 +257,67 @@ export function createCoverage({ state, source }) {
|
||||
removeWhenReady(entry, previous);
|
||||
}
|
||||
|
||||
/** The mesh cells under a tile's drawn lines, built once per filter. */
|
||||
function meshCells(entry) {
|
||||
if (entry.meshCells) return entry.meshCells;
|
||||
const cells = new Map();
|
||||
for (const sequence of entry.sequences.values())
|
||||
for (const [lon, lat] of sequence.parts.flatMap((part) =>
|
||||
densifyLine(part, MESH_DENSIFY_DEG),
|
||||
))
|
||||
cells.set(
|
||||
`${Math.round(lon / MESH_CELL_DEG)},${Math.round(lat / MESH_CELL_DEG)}`,
|
||||
[lon, lat],
|
||||
);
|
||||
entry.meshCells = [...cells.values()];
|
||||
return entry.meshCells;
|
||||
/** The point under the camera: where the mesh sampler measures its range from. */
|
||||
function meshCentre() {
|
||||
const carto = state.viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
return {
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ask for mesh samples under a cast tile's lines (the sampler keeps the near ones). */
|
||||
/** Whether any of a {west, south, east, north} box is within the sampler's range. */
|
||||
function inMeshRange(box, centre) {
|
||||
const nearest = {
|
||||
lon: Math.min(box.east, Math.max(box.west, centre.lon)),
|
||||
lat: Math.min(box.north, Math.max(box.south, centre.lat)),
|
||||
};
|
||||
return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile's drawn lines with their bounding boxes, built once per filter;
|
||||
* each line's mesh points are densified the first time it is in range.
|
||||
*/
|
||||
function meshParts(entry) {
|
||||
if (entry.meshParts) return entry.meshParts;
|
||||
entry.meshParts = [];
|
||||
for (const sequence of entry.sequences.values())
|
||||
for (const part of sequence.parts) {
|
||||
const box = {
|
||||
west: Infinity,
|
||||
south: Infinity,
|
||||
east: -Infinity,
|
||||
north: -Infinity,
|
||||
};
|
||||
for (const [lon, lat] of part) {
|
||||
if (lon < box.west) box.west = lon;
|
||||
if (lon > box.east) box.east = lon;
|
||||
if (lat < box.south) box.south = lat;
|
||||
if (lat > box.north) box.north = lat;
|
||||
}
|
||||
entry.meshParts.push({ part, box, points: null });
|
||||
}
|
||||
return entry.meshParts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for mesh samples under a cast tile's lines within the sampler's range
|
||||
* of the camera; the rest are asked for when a camera move brings them in.
|
||||
*/
|
||||
function requestMesh(entry) {
|
||||
const sampler = state.context.meshSampler;
|
||||
if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return;
|
||||
sampler.request(meshCells(entry));
|
||||
const centre = meshCentre();
|
||||
if (!centre || !entry.bounds || !inMeshRange(entry.bounds, centre)) return;
|
||||
const points = [];
|
||||
for (const line of meshParts(entry)) {
|
||||
if (!inMeshRange(line.box, centre)) continue;
|
||||
line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG);
|
||||
for (const point of line.points) points.push(point);
|
||||
}
|
||||
if (points.length) sampler.request(points);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,7 +423,7 @@ export function createCoverage({ state, source }) {
|
||||
color: green,
|
||||
pixelSize: COVERAGE_OVERVIEW_POINT_PX,
|
||||
// Google 3D terrain and clouds must not hide the near side's dots;
|
||||
// `cullHorizon` hides the far side's, which this lets through.
|
||||
// the horizon cull hides the far side's, which this lets through.
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
}),
|
||||
);
|
||||
@@ -390,53 +431,18 @@ export function createCoverage({ state, source }) {
|
||||
return { collection, points: drawn };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the overview points behind the horizon, as the cyclones layer does.
|
||||
* They skip the depth test, so nothing else stops the far hemisphere's
|
||||
* coverage drawing over this one at whole-earth zooms; a finite skip
|
||||
* distance cannot do it, as the horizon's distance moves with the camera.
|
||||
*/
|
||||
function cullHorizon() {
|
||||
const position = state.viewer?.camera?.positionWC;
|
||||
if (!position) return;
|
||||
const entries = [
|
||||
...state.coverage.tiles.values(),
|
||||
...state.coverage.stale.values(),
|
||||
].filter((entry) => entry.overviewPoints);
|
||||
if (!entries.length) {
|
||||
stopHorizonCull();
|
||||
return;
|
||||
}
|
||||
const from = state.coverage.cullFrom;
|
||||
if (from && Cesium.Cartesian3.equals(from, position)) return;
|
||||
state.coverage.cullFrom = Cesium.Cartesian3.clone(position, from);
|
||||
let changed = false;
|
||||
for (const entry of entries)
|
||||
if (cullPoints(entry.overviewPoints, position)) changed = true;
|
||||
if (changed) requestRender();
|
||||
/** Every overview point on the globe, current zoom and old alike. */
|
||||
function* overviewPoints() {
|
||||
for (const entry of drawnEntries())
|
||||
if (entry.overviewPoints) yield* entry.overviewPoints;
|
||||
}
|
||||
|
||||
/** Show the points in front of the horizon seen from `position`, hide the rest. */
|
||||
function cullPoints(points, position) {
|
||||
const occluder = (state.coverage.occluder ||=
|
||||
new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84));
|
||||
occluder.cameraPosition = position;
|
||||
let changed = false;
|
||||
for (const point of points) {
|
||||
const show = occluder.isPointVisible(point.position);
|
||||
if (point.show !== show) {
|
||||
point.show = show;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function stopHorizonCull() {
|
||||
state.coverage.stopHorizonCull?.();
|
||||
state.coverage.stopHorizonCull = null;
|
||||
state.coverage.cullFrom = null;
|
||||
}
|
||||
// Overview points skip the depth test: hide the far hemisphere's.
|
||||
const horizon = createHorizonCull({
|
||||
getViewer: () => state.viewer,
|
||||
items: overviewPoints,
|
||||
onChange: requestRender,
|
||||
});
|
||||
|
||||
function attachPrimitive(entry) {
|
||||
const scene = state.viewer?.scene;
|
||||
@@ -463,11 +469,8 @@ export function createCoverage({ state, source }) {
|
||||
entry.overviewPoints = points;
|
||||
entry.count = points.length;
|
||||
scene.primitives.add(collection);
|
||||
// Cull the new points now, then every frame the camera has moved.
|
||||
const camera = state.viewer.camera?.positionWC;
|
||||
if (camera) cullPoints(points, camera);
|
||||
state.coverage.stopHorizonCull ||=
|
||||
scene.preRender?.addEventListener(cullHorizon) || null;
|
||||
// Cull the new points now, then all of them whenever the camera moves.
|
||||
horizon.update(points);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -777,7 +780,7 @@ export function createCoverage({ state, source }) {
|
||||
state.coverage.remeshTimer = null;
|
||||
state.coverage.remeshDirty.clear();
|
||||
stopSelectionWatch();
|
||||
stopHorizonCull();
|
||||
horizon.stop();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
@@ -787,7 +790,7 @@ export function createCoverage({ state, source }) {
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
cancelCast(entry);
|
||||
entry.castRequested = false;
|
||||
entry.meshCells = null;
|
||||
entry.meshParts = null;
|
||||
detachPrimitive(entry);
|
||||
attachPrimitive(entry);
|
||||
}
|
||||
@@ -823,12 +826,26 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tile drawn on the globe: the current zoom's, and the old zoom's
|
||||
* kept visible until the new one is in (see retire).
|
||||
*/
|
||||
function drawnEntries() {
|
||||
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
|
||||
}
|
||||
|
||||
/** A tile's primitives on the globe: its own, and the ones a swap still shows. */
|
||||
function drawnRecords(entry) {
|
||||
const records = entry.primitives || [];
|
||||
return entry.swap ? [...records, ...entry.swap.old] : records;
|
||||
}
|
||||
|
||||
/** Recolour one sequence, every part of it, in place (selection highlight). */
|
||||
function recolorSequence(id, selected) {
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
for (const entry of drawnEntries()) {
|
||||
const sequence = entry.sequences.get(id);
|
||||
if (!sequence) continue;
|
||||
for (const record of entry.primitives || []) {
|
||||
for (const record of drawnRecords(entry)) {
|
||||
// Still building: `syncSelection` catches it up once it is ready.
|
||||
if (!record.primitive.ready) continue;
|
||||
recolorInstances(record.primitive, sequence, selected);
|
||||
@@ -867,11 +884,15 @@ export function createCoverage({ state, source }) {
|
||||
const stop = scene.postRender.addEventListener(() => {
|
||||
let building = false;
|
||||
let changed = false;
|
||||
for (const entry of state.coverage.tiles.values())
|
||||
for (const entry of drawnEntries()) {
|
||||
for (const record of entry.primitives || []) {
|
||||
if (!record.primitive.ready) building = true;
|
||||
else if (syncSelection(entry, record)) changed = true;
|
||||
}
|
||||
// Lines a swap still shows were ready (or never show): catch up only.
|
||||
for (const record of entry.swap?.old || [])
|
||||
if (syncSelection(entry, record)) changed = true;
|
||||
}
|
||||
if (changed) requestRender();
|
||||
if (!building) stopSelectionWatch();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user