mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24: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
@@ -14,6 +14,8 @@
|
||||
* and the caller keeps its draped placement.
|
||||
*/
|
||||
|
||||
import { wrapLon } from './tileMath.js';
|
||||
|
||||
/** 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. */
|
||||
@@ -107,6 +109,8 @@ export function nextSurfaceMode(current, { photoreal, heightM, available }) {
|
||||
|
||||
/**
|
||||
* Split long segments so a line follows the terrain between its vertices.
|
||||
* A segment across ±180° is split the short way round, and the points it
|
||||
* adds are wrapped back into [-180, 180].
|
||||
* @param {Array<[number, number]>} coords [lon, lat] pairs
|
||||
* @param {number} [maxStep] longest segment, in degrees
|
||||
* @returns {Array<[number, number]>}
|
||||
@@ -117,11 +121,12 @@ export function densifyLine(coords, maxStep = GROUND_CAST_DENSIFY_DEG) {
|
||||
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 dLon = wrapLon(lon1 - lon0);
|
||||
const span = Math.max(Math.abs(dLon), 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([wrapLon(lon0 + dLon * t), lat0 + (lat1 - lat0) * t]);
|
||||
}
|
||||
out.push(coords[i]);
|
||||
}
|
||||
@@ -177,9 +182,11 @@ export function createGroundCaster({
|
||||
const missing = missingCorners(points);
|
||||
if (!missing.length) return true;
|
||||
if (missing.length > maxCorners) return false;
|
||||
// A corner east of 180° (the far corner of a point on the meridian) is
|
||||
// asked for at its longitude on the other side.
|
||||
const results = await terrain.resolveEllipsoidalGround(
|
||||
missing.map(([, { i, j }]) => ({
|
||||
lon: Number((i * step).toFixed(6)),
|
||||
lon: Number(wrapLon(i * step).toFixed(6)),
|
||||
lat: Number((j * step).toFixed(6)),
|
||||
})),
|
||||
{ signal },
|
||||
|
||||
@@ -83,6 +83,32 @@ test('densifyLine splits long segments and keeps the vertices', () => {
|
||||
assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]);
|
||||
});
|
||||
|
||||
test('densifyLine crosses the date line the short way and wraps what it adds (review IC8 P2)', () => {
|
||||
// 0.0002° apart: short enough to stay one segment, not 65 points via 0°.
|
||||
const short = [
|
||||
[179.9999, 0],
|
||||
[-179.9999, 0],
|
||||
];
|
||||
assert.deepEqual(densifyLine(short), short);
|
||||
const line = [
|
||||
[179.999, 50.0005],
|
||||
[-179.999, 50.0005],
|
||||
];
|
||||
const dense = densifyLine(line, 0.0005);
|
||||
assert.equal(dense.length, 5);
|
||||
assert.deepEqual(dense[0], line[0]);
|
||||
assert.deepEqual(dense.at(-1), line[1]);
|
||||
for (const [lon] of dense)
|
||||
assert.ok(lon >= -180 && lon <= 180 && Math.abs(lon) >= 179.999, `${lon}`);
|
||||
assert.ok(Math.abs(dense[1][0] - 179.9995) < 1e-9);
|
||||
assert.ok(Math.abs(Math.abs(dense[2][0]) - 180) < 1e-9);
|
||||
assert.ok(Math.abs(dense[3][0] - -179.9995) < 1e-9);
|
||||
// Westward across the date line too.
|
||||
const back = densifyLine([...line].reverse(), 0.0005);
|
||||
assert.equal(back.length, 5);
|
||||
assert.ok(Math.abs(back[1][0] - -179.9995) < 1e-9);
|
||||
});
|
||||
|
||||
test('the caster interpolates cached grid corners and adds the lift', async () => {
|
||||
const terrain = fakeTerrain();
|
||||
const caster = createGroundCaster({ terrain, step: 0.001 });
|
||||
@@ -305,3 +331,41 @@ test('prepared lines always cast with the mesh, however their segments cross the
|
||||
}
|
||||
assert.equal(failures, 0);
|
||||
});
|
||||
|
||||
test('a line across the date line casts along the date line (review IC8 P2)', async () => {
|
||||
// Ground that rises northward only, so both sides of ±180° agree.
|
||||
const calls = [];
|
||||
const terrain = {
|
||||
async resolveEllipsoidalGround(coords) {
|
||||
calls.push(...coords);
|
||||
return coords.map(({ lat }) => ({
|
||||
ellipsoid: 100 + (lat - 50) * 2000,
|
||||
source: 'reearth',
|
||||
}));
|
||||
},
|
||||
};
|
||||
const caster = createGroundCaster({ terrain, step: 0.001 });
|
||||
const line = [
|
||||
[179.9995, 50.0005],
|
||||
[180, 50.0005],
|
||||
[-179.9995, 50.0005],
|
||||
];
|
||||
assert.equal(await caster.prepareLines([line]), true);
|
||||
for (const meshAt of [null, () => undefined]) {
|
||||
const flat = caster.castLine(line, { meshAt });
|
||||
assert.ok(flat, 'every point casts');
|
||||
assert.ok(
|
||||
flat.length / 3 <= 8,
|
||||
`${flat.length / 3} points, not a line round the world`,
|
||||
);
|
||||
for (let i = 0; i < flat.length; i += 3) {
|
||||
assert.ok(Math.abs(flat[i]) >= 179.999 && Math.abs(flat[i]) <= 180);
|
||||
assert.ok(Math.abs(flat[i + 2] - (101 + GROUND_CAST_LIFT_M)) < 1e-6);
|
||||
}
|
||||
}
|
||||
// Only ground near the date line is asked for, and every longitude is a
|
||||
// real one (the far corner of a point on 180° is asked for at -179.999°).
|
||||
assert.ok(calls.length <= 16, `${calls.length} corners`);
|
||||
for (const { lon } of calls)
|
||||
assert.ok(Math.abs(lon) >= 179.999 && Math.abs(lon) <= 180, `${lon}`);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import * as Cesium from 'cesium';
|
||||
import { positionMarkerGlyph } from './glyphs.js';
|
||||
import { COLORS, POSITION_PICK_ID } from './policy.js';
|
||||
import { refineHeights } from './groundCast.js';
|
||||
import { createHorizonCull } from './view.js';
|
||||
|
||||
const SPRITE_ID = 'street-level:marker';
|
||||
|
||||
@@ -18,6 +19,13 @@ export function createMarker({ state, parts }) {
|
||||
render?.governorRequestRender?.('street-level-marker');
|
||||
}
|
||||
|
||||
// It skips the depth test: hide it while it is behind the globe.
|
||||
const horizon = createHorizonCull({
|
||||
getViewer: () => state.viewer,
|
||||
items: () => (state.marker.billboard ? [state.marker.billboard] : []),
|
||||
onChange: requestRender,
|
||||
});
|
||||
|
||||
function ensure(viewer) {
|
||||
if (state.marker.collection) return;
|
||||
state.marker.collection = new Cesium.BillboardCollection({
|
||||
@@ -61,6 +69,7 @@ export function createMarker({ state, parts }) {
|
||||
if (!position) {
|
||||
collection.removeAll();
|
||||
state.marker.billboard = null;
|
||||
horizon.stop();
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
@@ -92,6 +101,7 @@ export function createMarker({ state, parts }) {
|
||||
state.marker.billboard.position = cartesian;
|
||||
}
|
||||
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
|
||||
horizon.update([state.marker.billboard]);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
@@ -101,6 +111,7 @@ export function createMarker({ state, parts }) {
|
||||
}
|
||||
|
||||
function destroy(viewer) {
|
||||
horizon.stop();
|
||||
const collection = state.marker.collection;
|
||||
if (!collection) return;
|
||||
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createMarker } from './marker.js';
|
||||
|
||||
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
|
||||
const noop = () => {};
|
||||
const context2d = new Proxy({}, { get: () => noop, set: () => true });
|
||||
globalThis.document ??= {
|
||||
createElement: () => ({ getContext: () => context2d }),
|
||||
};
|
||||
|
||||
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
|
||||
function setup() {
|
||||
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
||||
const preRender = new Set();
|
||||
const viewer = {
|
||||
camera: {
|
||||
get positionWC() {
|
||||
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
primitives: { add: (p) => p, remove() {} },
|
||||
preRender: {
|
||||
addEventListener(listener) {
|
||||
preRender.add(listener);
|
||||
return () => preRender.delete(listener);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
services: {},
|
||||
viewer,
|
||||
surface: 'draped',
|
||||
marker: {
|
||||
// Stands in for the billboard collection; ensure() keeps it.
|
||||
collection: {
|
||||
show: true,
|
||||
add: (options) => ({ ...options }),
|
||||
removeAll() {},
|
||||
},
|
||||
billboard: null,
|
||||
},
|
||||
};
|
||||
const marker = createMarker({ state, parts: {} });
|
||||
const frame = () => {
|
||||
for (const listener of [...preRender]) listener();
|
||||
};
|
||||
return { state, view, preRender, marker, frame };
|
||||
}
|
||||
|
||||
test('the photo marker is hidden behind the globe, and shows from its side (review IC8 P2)', () => {
|
||||
const { state, view, preRender, marker, frame } = setup();
|
||||
// Placed on the far side of the Earth from the camera.
|
||||
marker.set({ lon: 58.51, lat: -38.58 }, 90);
|
||||
const billboard = state.marker.billboard;
|
||||
assert.equal(billboard.show, false, 'not drawn through the globe');
|
||||
// Fly round to its side: it shows.
|
||||
view.lon = 58.51;
|
||||
view.lat = -38.58;
|
||||
frame();
|
||||
assert.equal(billboard.show, true);
|
||||
// Moved to the far side again, without the camera moving.
|
||||
marker.set({ lon: -121.49, lat: 38.58 }, 0);
|
||||
assert.equal(billboard.show, false, 'culled where it now stands');
|
||||
marker.clear();
|
||||
assert.equal(preRender.size, 0, 'the cull listener is gone');
|
||||
});
|
||||
|
||||
test('destroying the marker stops the horizon cull', () => {
|
||||
const { marker, preRender } = setup();
|
||||
marker.set({ lon: -121.49, lat: 38.58 }, 0);
|
||||
assert.equal(preRender.size, 1);
|
||||
marker.destroy();
|
||||
assert.equal(preRender.size, 0);
|
||||
});
|
||||
@@ -9,7 +9,11 @@ import { metresBetween, whenIdle } from './view.js';
|
||||
* `sampleHeight` renders a pick pass per probe (about 1–3 ms), so probes run
|
||||
* in idle time within a small budget, nearest the camera first, and only
|
||||
* within MESH_SAMPLE_RADIUS_M of the point under the camera: the streamed
|
||||
* detail is fine there, and an error is visible at all. A probe that hits
|
||||
* detail is fine there, and an error is visible at all. Requests are taken
|
||||
* in by the same budgeted slices, cells out of range then are not queued
|
||||
* (callers ask again when the camera moves), and the queue is a heap ranked
|
||||
* from where the camera was, re-ranked only once it has moved MESH_RERANK_M,
|
||||
* so no slice walks or sorts the whole queue. A probe that hits
|
||||
* nothing (tiles not streamed yet) is retried later rather than latched.
|
||||
* Overlays are excluded from the probe, so only 3D tilesets are hit.
|
||||
*/
|
||||
@@ -18,8 +22,10 @@ import { metresBetween, whenIdle } from './view.js';
|
||||
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 spent probing per idle slice. */
|
||||
/** 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;
|
||||
/** Cached cells (and remembered misses) before they are dropped and refilled. */
|
||||
@@ -28,7 +34,52 @@ const MESH_CACHE_MAX = 80_000;
|
||||
const MESH_NOTIFY_MS = 700;
|
||||
|
||||
const cellOf = (value) => Math.round(value / MESH_CELL_DEG);
|
||||
const keyOf = (lon, lat) => `${cellOf(lon)},${cellOf(lat)}`;
|
||||
|
||||
/**
|
||||
* The cache key of the mesh cell holding a point, one per ~11 m cell: a
|
||||
* number (exact well within 2^53), as a string key would be built per point.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** Take the nearest cell off a min-heap. */
|
||||
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) {
|
||||
@@ -42,13 +93,14 @@ function overlays(scene) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{getViewer: () => object|null, budgetMs?: number, radiusM?: number, cacheMax?: number}} options
|
||||
* @param {{getViewer: () => object|null, budgetMs?: number, radiusM?: number, cacheMax?: number, now?: () => number}} options
|
||||
*/
|
||||
export function createMeshSampler({
|
||||
getViewer,
|
||||
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();
|
||||
@@ -58,8 +110,16 @@ export function createMeshSampler({
|
||||
* height cache, so the map stays bounded.
|
||||
*/
|
||||
const misses = new Map();
|
||||
/** cell key → {lon, lat} cell centre waiting to be probed. */
|
||||
/** 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;
|
||||
@@ -68,32 +128,90 @@ export function createMeshSampler({
|
||||
|
||||
/** The sampled mesh height for a point, or undefined when not sampled. */
|
||||
function meshAt(lon, lat) {
|
||||
return heights.get(keyOf(lon, lat));
|
||||
return heights.get(meshCellKey(lon, lat));
|
||||
}
|
||||
|
||||
/** Queue the cells under these [lon, lat] points for probing. */
|
||||
/** The point under the camera, 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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for the cells under these [lon, lat] points (read later, so do not
|
||||
* change the list). Nothing is done here: idle slices take requests in
|
||||
* within their budget, and drop cells out of range of where the camera was
|
||||
* when asked; callers ask again once it moves.
|
||||
*/
|
||||
function request(points) {
|
||||
if (!enabled) return;
|
||||
const now = Date.now();
|
||||
for (const [lon, lat] of points) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||||
const key = keyOf(lon, lat);
|
||||
if (heights.has(key) || wanted.has(key)) continue;
|
||||
const retryAt = misses.get(key);
|
||||
if (retryAt !== undefined) {
|
||||
if (retryAt > now) continue;
|
||||
misses.delete(key);
|
||||
}
|
||||
wanted.set(key, {
|
||||
lon: cellOf(lon) * MESH_CELL_DEG,
|
||||
lat: cellOf(lat) * MESH_CELL_DEG,
|
||||
});
|
||||
}
|
||||
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 (heights.has(key) || wanted.has(key)) continue;
|
||||
const cell = {
|
||||
key,
|
||||
lon: cellOf(lon) * MESH_CELL_DEG,
|
||||
lat: cellOf(lat) * MESH_CELL_DEG,
|
||||
distance: 0,
|
||||
};
|
||||
if (metresBetween(cell, centre) > radiusM) 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 || !wanted.size) return;
|
||||
if (running || !enabled || !(queue.length || intake.length)) return;
|
||||
running = true;
|
||||
whenIdle(step, 300);
|
||||
}
|
||||
@@ -113,32 +231,38 @@ export function createMeshSampler({
|
||||
|
||||
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 carto = getViewer()?.camera?.positionCartographic;
|
||||
if (!enabled || !scene?.sampleHeightSupported || !carto) return;
|
||||
const centre = {
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
};
|
||||
// Nearest first; cells out of range are dropped (they stay bare earth).
|
||||
const order = [];
|
||||
for (const [key, point] of wanted) {
|
||||
const distance = metresBetween(point, centre);
|
||||
if (distance > radiusM) wanted.delete(key);
|
||||
else order.push([distance, key, point]);
|
||||
const centre = cameraCentre();
|
||||
if (!enabled || !scene?.sampleHeightSupported || !centre) 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;
|
||||
}
|
||||
order.sort((a, b) => a[0] - b[0]);
|
||||
const slice = Math.max(
|
||||
1,
|
||||
Math.min(budgetMs * 2, deadline?.timeRemaining?.() ?? budgetMs),
|
||||
);
|
||||
const until = performance.now() + Math.min(budgetMs, slice);
|
||||
const exclude = overlays(scene);
|
||||
let probed = 0;
|
||||
for (const [, key, { lon, lat }] of order) {
|
||||
if (probed && performance.now() > until) break;
|
||||
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.
|
||||
if (metresBetween({ lon, lat }, centre) > radiusM) continue;
|
||||
probed++;
|
||||
const probeStarted = now();
|
||||
let height;
|
||||
try {
|
||||
height = scene.sampleHeight(
|
||||
@@ -148,6 +272,7 @@ export function createMeshSampler({
|
||||
} catch {
|
||||
height = undefined;
|
||||
}
|
||||
probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25;
|
||||
if (!Number.isFinite(height)) {
|
||||
if (misses.size >= cacheMax) misses.clear();
|
||||
misses.set(key, Date.now() + MESH_MISS_RETRY_MS);
|
||||
@@ -172,7 +297,7 @@ export function createMeshSampler({
|
||||
/** Probe only while overlays are cast (Google 3D at street zoom). */
|
||||
function setEnabled(on) {
|
||||
enabled = on === true;
|
||||
if (!enabled) wanted.clear();
|
||||
if (!enabled) clearQueue();
|
||||
else schedule();
|
||||
}
|
||||
|
||||
@@ -185,7 +310,7 @@ export function createMeshSampler({
|
||||
function destroy() {
|
||||
enabled = false;
|
||||
forget();
|
||||
wanted.clear();
|
||||
clearQueue();
|
||||
listeners.clear();
|
||||
clearTimeout(notifyTimer);
|
||||
notifyTimer = null;
|
||||
|
||||
@@ -4,8 +4,10 @@ import * as Cesium from 'cesium';
|
||||
import {
|
||||
MESH_CELL_DEG,
|
||||
MESH_MISS_RETRY_MS,
|
||||
MESH_SAMPLE_BUDGET_MS,
|
||||
createMeshSampler,
|
||||
} from './meshSampler.js';
|
||||
import { metresBetween } from './view.js';
|
||||
|
||||
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const RAD = Math.PI / 180;
|
||||
@@ -136,3 +138,132 @@ test('destroy forgets the samples', (t) => {
|
||||
sampler.destroy();
|
||||
assert.equal(sampler.meshAt(10.001, 50), undefined);
|
||||
});
|
||||
|
||||
/**
|
||||
* A sampler over `fakeViewer` on a fake clock: each probe costs `probeMs`
|
||||
* and each read of the camera `cameraMs` (work done before any probe).
|
||||
*/
|
||||
function clocked(t, { probeMs = 2, cameraMs = 0, holes } = {}) {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const fake = fakeViewer({ holes });
|
||||
const clock = { now: 0 };
|
||||
const { scene, camera } = fake.viewer;
|
||||
const probe = scene.sampleHeight;
|
||||
scene.sampleHeight = (...args) => {
|
||||
clock.now += probeMs;
|
||||
return probe(...args);
|
||||
};
|
||||
const position = { ...camera.positionCartographic };
|
||||
Object.defineProperty(camera, 'positionCartographic', {
|
||||
get: () => {
|
||||
clock.now += cameraMs;
|
||||
return position;
|
||||
},
|
||||
});
|
||||
const sampler = createMeshSampler({
|
||||
getViewer: () => fake.viewer,
|
||||
now: () => clock.now,
|
||||
});
|
||||
sampler.setEnabled(true);
|
||||
/** Run one idle slice; its length on the fake clock. */
|
||||
function slice() {
|
||||
const started = clock.now;
|
||||
probeNow(t);
|
||||
return clock.now - started;
|
||||
}
|
||||
/** Put the camera over a point. */
|
||||
function moveTo(lon, lat) {
|
||||
position.longitude = lon * RAD;
|
||||
position.latitude = lat * RAD;
|
||||
}
|
||||
return { ...fake, sampler, slice, moveTo };
|
||||
}
|
||||
|
||||
/** Points on a grid `step` degrees apart, `n`×`n`, centred on a point. */
|
||||
function grid(lon, lat, n, step) {
|
||||
const points = [];
|
||||
for (let i = 0; i < n; i++)
|
||||
for (let j = 0; j < n; j++)
|
||||
points.push([
|
||||
lon + (i - (n - 1) / 2) * step,
|
||||
lat + (j - (n - 1) / 2) * step,
|
||||
]);
|
||||
return points;
|
||||
}
|
||||
|
||||
test('cells out of range when asked are not queued, even once the camera reaches them (review IC8 P2)', (t) => {
|
||||
const { sampler, probes, slice, moveTo } = clocked(t);
|
||||
// 400 cells ~3.6 km east, past the sampler's range.
|
||||
sampler.request(grid(10.05, 50, 20, 0.0002));
|
||||
// The camera gets there before any idle slice runs.
|
||||
moveTo(10.05, 50);
|
||||
for (let i = 0; i < 5; i++) slice();
|
||||
assert.equal(probes.length, 0, 'nothing was queued for later');
|
||||
// Asked again from there (as a camera move does), they are probed.
|
||||
sampler.request([[10.05, 50]]);
|
||||
slice();
|
||||
assert.equal(probes.length, 1);
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('a slice keeps to its budget, counting the work before its first probe (review IC8 P2)', (t) => {
|
||||
const { sampler, probes, slice } = clocked(t, { probeMs: 2, cameraMs: 3 });
|
||||
sampler.request(grid(10.001, 50, 5, 0.0002));
|
||||
const lengths = [];
|
||||
while (probes.length < 25 && lengths.length < 40) lengths.push(slice());
|
||||
assert.equal(probes.length, 25, 'every cell is probed in the end');
|
||||
for (const length of lengths)
|
||||
assert.ok(length <= MESH_SAMPLE_BUDGET_MS, `a ${length} ms slice`);
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('a large queue is ranked once, not sorted again every slice (review IC8 P2)', (t) => {
|
||||
const { sampler, probes, slice, moveTo } = clocked(t);
|
||||
// 2,500 cells ~17 m apart around the camera, all within range.
|
||||
const cells = grid(10, 50, 50, 0.00015);
|
||||
sampler.request(cells);
|
||||
const sort = Array.prototype.sort;
|
||||
let sorted = 0;
|
||||
t.after(() => (Array.prototype.sort = sort));
|
||||
Array.prototype.sort = function (...args) {
|
||||
if (this.length >= 100) sorted += this.length;
|
||||
return sort.apply(this, args);
|
||||
};
|
||||
for (let i = 0; i < 10; i++) slice();
|
||||
const first = probes.map((probe) =>
|
||||
metresBetween(probe, { lon: 10, lat: 50 }),
|
||||
);
|
||||
assert.ok(first.length >= 10, 'probes ran');
|
||||
first.forEach((distance, i) =>
|
||||
assert.ok(
|
||||
!i || distance >= first[i - 1] - 0.01,
|
||||
'nearest the camera first, across slices',
|
||||
),
|
||||
);
|
||||
Array.prototype.sort = sort;
|
||||
assert.equal(sorted, 0, `${sorted} queued cells sorted in 10 slices`);
|
||||
// The camera moves ~200 m east: the queue is ranked from there.
|
||||
const done = probes.length;
|
||||
moveTo(10.003, 50);
|
||||
slice();
|
||||
slice();
|
||||
const after = probes
|
||||
.slice(done)
|
||||
.map((probe) => metresBetween(probe, { lon: 10.003, lat: 50 }));
|
||||
assert.ok(after.length > 0);
|
||||
assert.ok(after[0] < 20, `nearest the new view first (${after[0]} m)`);
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('the sampling range is measured the short way round the date line (review IC8 P2)', (t) => {
|
||||
const { sampler, probes, slice, moveTo } = clocked(t);
|
||||
moveTo(179.9999, 0);
|
||||
sampler.request([
|
||||
[-179.9999, 0], // ~22 m away, across the date line
|
||||
[179.99, 0], // ~1.1 km away, past the range
|
||||
]);
|
||||
for (let i = 0; i < 5; i++) slice();
|
||||
assert.equal(probes.length, 1, 'only the neighbour across ±180°');
|
||||
assert.ok(Math.abs(Math.abs(probes[0].lon) - 179.9999) < MESH_CELL_DEG);
|
||||
sampler.destroy();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -512,49 +512,52 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
/** The colour attribute value of a line, selected or not. */
|
||||
function colourValue(selected) {
|
||||
return Array.from(
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
selected
|
||||
? Cesium.Color.fromCssColorString(COLORS.selected)
|
||||
: Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Take over a drawn primitive's readiness and seq-1's colour attribute. */
|
||||
function controlled(record, ready) {
|
||||
const control = { ready, attributes: { color: undefined } };
|
||||
Object.defineProperty(record.primitive, 'ready', {
|
||||
get: () => control.ready,
|
||||
});
|
||||
record.primitive.getGeometryInstanceAttributes = (id) =>
|
||||
id === 'mly:seq:seq-1' ? control.attributes : undefined;
|
||||
return control;
|
||||
}
|
||||
|
||||
test('a selection made or cleared while a tile builds is applied once it is ready', async () => {
|
||||
const { viewer, source, state, coverage, bytes } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
const [entry] = state.coverage.tiles.values();
|
||||
const value = (selected) =>
|
||||
Array.from(
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
selected
|
||||
? Cesium.Color.fromCssColorString(COLORS.selected)
|
||||
: Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92),
|
||||
),
|
||||
);
|
||||
/** Stand in for a primitive the worker has not finished, with its colours. */
|
||||
function building(record) {
|
||||
const control = { ready: false, attributes: { color: undefined } };
|
||||
Object.defineProperty(record.primitive, 'ready', {
|
||||
get: () => control.ready,
|
||||
});
|
||||
record.primitive.getGeometryInstanceAttributes = (id) =>
|
||||
id === 'mly:seq:seq-1' ? control.attributes : undefined;
|
||||
return control;
|
||||
}
|
||||
|
||||
// Selected while its line is still building: nothing to recolour yet...
|
||||
const first = building(entry.primitives[0]);
|
||||
const first = controlled(entry.primitives[0], false);
|
||||
state.sequence.selectedId = 'seq-1';
|
||||
coverage.recolorSequence('seq-1', true);
|
||||
assert.equal(first.attributes.color, undefined);
|
||||
// ...so the highlight lands once it is ready.
|
||||
first.ready = true;
|
||||
frame(viewer.postRender);
|
||||
assert.deepEqual(Array.from(first.attributes.color), value(true));
|
||||
assert.deepEqual(Array.from(first.attributes.color), colourValue(true));
|
||||
|
||||
// Rebuilt with the highlight baked in, then cleared mid-build: not stuck.
|
||||
coverage.rebuild();
|
||||
const second = building(entry.primitives[0]);
|
||||
const second = controlled(entry.primitives[0], false);
|
||||
coverage.recolorSequence('seq-1', false);
|
||||
state.sequence.selectedId = null;
|
||||
second.ready = true;
|
||||
frame(viewer.postRender);
|
||||
assert.deepEqual(Array.from(second.attributes.color), value(false));
|
||||
assert.deepEqual(Array.from(second.attributes.color), colourValue(false));
|
||||
coverage.clear();
|
||||
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
|
||||
});
|
||||
@@ -727,3 +730,112 @@ test('a street view toward the horizon ranks only the ground within range', () =
|
||||
assert.ok(Math.abs(dx) <= 1 && Math.abs(dy) <= 1, `${dx},${dy} around it`);
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a selection cleared while the old zoom is still shown uncolours its lines (review IC8 P2)', async () => {
|
||||
const { viewer, source, state, coverage, bytes } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
const [entry] = state.coverage.tiles.values();
|
||||
const line = controlled(entry.primitives[0], true);
|
||||
state.sequence.selectedId = 'seq-1';
|
||||
coverage.recolorSequence('seq-1', true);
|
||||
assert.deepEqual(Array.from(line.attributes.color), colourValue(true));
|
||||
// Zoom out: the street tile stays on screen while the new zoom loads...
|
||||
viewer.view.height = 100_000;
|
||||
coverage.refresh();
|
||||
assert.equal(state.coverage.stale.size, 1, 'the old zoom is kept');
|
||||
// ...and the selection is cleared meanwhile.
|
||||
state.sequence.selectedId = null;
|
||||
coverage.recolorSequence('seq-1', false);
|
||||
assert.deepEqual(
|
||||
Array.from(line.attributes.color),
|
||||
colourValue(false),
|
||||
'the retained line is not left cyan',
|
||||
);
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a selection made while the old zoom is still building is applied once it is ready (review IC8 P2)', async () => {
|
||||
const { viewer, source, state, coverage, bytes } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
const [entry] = state.coverage.tiles.values();
|
||||
const line = controlled(entry.primitives[0], false);
|
||||
viewer.view.height = 100_000;
|
||||
coverage.refresh();
|
||||
assert.equal(state.coverage.stale.size, 1);
|
||||
frame(viewer.postRender);
|
||||
state.sequence.selectedId = 'seq-1';
|
||||
coverage.recolorSequence('seq-1', true);
|
||||
line.ready = true;
|
||||
frame(viewer.postRender);
|
||||
assert.deepEqual(
|
||||
line.attributes.color && Array.from(line.attributes.color),
|
||||
colourValue(true),
|
||||
'the highlight lands on the retained line',
|
||||
);
|
||||
coverage.clear();
|
||||
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
|
||||
});
|
||||
|
||||
test('only lines within the mesh sampler range of the camera are asked for (review IC8 P2)', async () => {
|
||||
let heightsReady = false;
|
||||
const groundCaster = {
|
||||
prepareLines: async () => (heightsReady = true),
|
||||
castLine: (coords) =>
|
||||
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
|
||||
};
|
||||
const requested = [];
|
||||
const meshSampler = {
|
||||
onSampled() {},
|
||||
request: (points) => requested.push(...points),
|
||||
meshAt: () => undefined,
|
||||
};
|
||||
const { source, coverage, centre } = setup({
|
||||
surface: 'terrain',
|
||||
groundCaster,
|
||||
meshSampler,
|
||||
});
|
||||
const { east, north } = tileBounds(centre.tile.x, centre.tile.y, 14);
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(
|
||||
encodeCoverageTile(centre.tile, {
|
||||
sequences: [
|
||||
{
|
||||
id: 'here',
|
||||
parts: [
|
||||
[
|
||||
[centre.lon - 0.001, centre.lat],
|
||||
[centre.lon + 0.001, centre.lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
// ~1.1 km from the camera, in the tile's north-east corner.
|
||||
{
|
||||
id: 'corner',
|
||||
parts: [
|
||||
[
|
||||
[east - 0.002, north - 0.002],
|
||||
[east - 0.0005, north - 0.0005],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
assert.ok(requested.length > 0, 'the near line is asked for');
|
||||
const farthest = Math.max(
|
||||
...requested.map(([lon, lat]) =>
|
||||
Math.hypot(
|
||||
(lon - centre.lon) * 111_320 * Math.cos(centre.lat * RAD),
|
||||
(lat - centre.lat) * 110_540,
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.ok(farthest < 200, `nothing from the corner (${farthest} m)`);
|
||||
coverage.clear();
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createMapillaryProvider, mapillaryImageUrl } from './index.js';
|
||||
import { sequenceIdFromPick } from './coverage.js';
|
||||
import { thinImages } from './sequences.js';
|
||||
import { validateProviders } from '../../registry.js';
|
||||
import { PROVIDER_COLORS } from '../../policy.js';
|
||||
import { resolveFilter } from '../../filter.js';
|
||||
@@ -135,3 +136,47 @@ test('a "since N days" window follows the clock in a long-open tab (review P3)',
|
||||
'two days on, the image is older than the window',
|
||||
);
|
||||
});
|
||||
|
||||
test('nearestImage measures the short way round the date line (review IC8 P2)', async () => {
|
||||
const at = (id, lon, lat) => ({
|
||||
id,
|
||||
is_pano: false,
|
||||
captured_at: 10,
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
});
|
||||
const source = fakeSource({
|
||||
nearest: [
|
||||
at('same-side', 179.9995, 0), // ~45 m west, on the camera's side
|
||||
at('across', -179.9999, 0), // ~11 m east, across ±180°
|
||||
],
|
||||
});
|
||||
const instance = createMapillaryProvider({ source }).create(fakeContext());
|
||||
assert.equal(
|
||||
await instance.nearestImage({ lat: 0, lon: 179.9999 }),
|
||||
'across',
|
||||
);
|
||||
// And from the other side.
|
||||
const west = createMapillaryProvider({
|
||||
source: fakeSource({
|
||||
nearest: [at('same-side', -179.9995, 0), at('across', 179.9999, 0)],
|
||||
}),
|
||||
}).create(fakeContext());
|
||||
assert.equal(await west.nearestImage({ lat: 0, lon: -179.9999 }), 'across');
|
||||
});
|
||||
|
||||
test('cone thinning across the date line drops images metres apart (review IC8 P2)', () => {
|
||||
const image = (id, lon) => ({ id, lon, lat: 0 });
|
||||
// A sequence driving east over ±180° with images ~1 m apart, then ~11 m on.
|
||||
const kept = thinImages(
|
||||
[
|
||||
image('a', 179.99999),
|
||||
image('b', -179.99999), // ~2 m from a, across the date line
|
||||
image('c', -179.9999), // ~11 m from a
|
||||
],
|
||||
3,
|
||||
);
|
||||
assert.deepEqual(
|
||||
kept.map(({ id }) => id),
|
||||
['a', 'c'],
|
||||
);
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import * as Cesium from 'cesium';
|
||||
import { imageConeGlyph } from '../../glyphs.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import { refineHeights } from '../../groundCast.js';
|
||||
import { createHorizonCull } from '../../view.js';
|
||||
import { meshCellKey } from '../../meshSampler.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import {
|
||||
COLORS,
|
||||
@@ -57,6 +59,21 @@ export function createSequences({ state, source, parts }) {
|
||||
state.context.notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* The cones on the globe: each drawn image's index, billboard and height
|
||||
* (null while clamped), and the mesh cells under them, so a mesh sample
|
||||
* moves only the cones it can affect.
|
||||
*/
|
||||
const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() });
|
||||
let drawn = nothingDrawn();
|
||||
|
||||
// The cones skip the depth test: hide the ones behind the globe.
|
||||
const horizon = createHorizonCull({
|
||||
getViewer: () => state.viewer,
|
||||
items: () => drawn.cones.map((cone) => cone.billboard),
|
||||
onChange: requestRender,
|
||||
});
|
||||
|
||||
function ensureCollections(viewer) {
|
||||
if (state.sequence.collection) return;
|
||||
state.sequence.collection = new Cesium.BillboardCollection({
|
||||
@@ -69,6 +86,23 @@ export function createSequences({ state, source, parts }) {
|
||||
function clearCones() {
|
||||
state.sequence.collection?.removeAll();
|
||||
state.sequence.images = [];
|
||||
drawn = nothingDrawn();
|
||||
horizon.stop();
|
||||
}
|
||||
|
||||
/** Where a cone stands: at `height`, or clamped to the ground when null. */
|
||||
function placement(image, height) {
|
||||
return {
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
image.lon,
|
||||
image.lat,
|
||||
height ?? 0,
|
||||
),
|
||||
heightReference:
|
||||
height === null
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +112,7 @@ export function createSequences({ state, source, parts }) {
|
||||
* draped, or when a terrain height is not cached yet; that fetches the
|
||||
* heights and draws the cones again.
|
||||
*/
|
||||
function coneHeights(images) {
|
||||
function coneHeights(images, { request = true } = {}) {
|
||||
const ground = state.context.groundCaster;
|
||||
if (state.context.getSurface?.() !== 'terrain' || !ground) return null;
|
||||
const dems = images.map((image) => ground.groundAt(image.lon, image.lat));
|
||||
@@ -91,7 +125,8 @@ export function createSequences({ state, source, parts }) {
|
||||
return null;
|
||||
}
|
||||
const sampler = state.context.meshSampler;
|
||||
sampler?.request(images.map((image) => [image.lon, image.lat]));
|
||||
if (request)
|
||||
sampler?.request(images.map((image) => [image.lon, image.lat]));
|
||||
return refineHeights(
|
||||
images.map((image, i) => ({
|
||||
dem: dems[i],
|
||||
@@ -104,6 +139,7 @@ export function createSequences({ state, source, parts }) {
|
||||
const collection = state.sequence.collection;
|
||||
if (!collection) return;
|
||||
collection.removeAll();
|
||||
drawn = { ...nothingDrawn(), images };
|
||||
const heights = coneHeights(images);
|
||||
// One colour per source; 360° images keep the ring shape.
|
||||
const cone = imageConeGlyph({ size: 32, color: COLORS.coverage });
|
||||
@@ -117,28 +153,56 @@ export function createSequences({ state, source, parts }) {
|
||||
images.forEach((image, index) => {
|
||||
if (!passesImageryFilter(image, filter)) return;
|
||||
const height = heights?.[index] ?? null;
|
||||
collection.add({
|
||||
const billboard = collection.add({
|
||||
id: `${PICK_PREFIX.image}${image.id}`,
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
image.lon,
|
||||
image.lat,
|
||||
height ?? 0,
|
||||
),
|
||||
...placement(image, height),
|
||||
image: image.isPano ? ring : cone,
|
||||
imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone',
|
||||
width: IMAGE_CONE_SIZE_PX,
|
||||
height: IMAGE_CONE_SIZE_PX,
|
||||
rotation: -Cesium.Math.toRadians(image.compassAngle),
|
||||
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
||||
heightReference:
|
||||
height === null
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
|
||||
});
|
||||
drawn.cones.push({ index, billboard, height });
|
||||
drawn.cells.add(meshCellKey(image.lon, image.lat));
|
||||
});
|
||||
horizon.update();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* New mesh samples landed: when any is under a cone, re-place the cones
|
||||
* whose cast height changed, in place. A sample can move a neighbour too
|
||||
* (refineHeights bridges across covered points), so every height is
|
||||
* recomputed (cheap arithmetic) but only moved cones are touched.
|
||||
*/
|
||||
function onMeshSampled(batch) {
|
||||
if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length)
|
||||
return;
|
||||
if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat))))
|
||||
return;
|
||||
const { images, cones } = drawn;
|
||||
// Already asked for when drawn; a missing terrain cell redraws them all.
|
||||
const heights = coneHeights(images, { request: false });
|
||||
if (!heights) return;
|
||||
const moved = [];
|
||||
for (const cone of cones) {
|
||||
const height = heights[cone.index] ?? null;
|
||||
if (height === cone.height) continue;
|
||||
cone.height = height;
|
||||
const { position, heightReference } = placement(
|
||||
images[cone.index],
|
||||
height,
|
||||
);
|
||||
cone.billboard.heightReference = heightReference;
|
||||
cone.billboard.position = position;
|
||||
moved.push(cone.billboard);
|
||||
}
|
||||
if (!moved.length) return;
|
||||
horizon.update(moved);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
@@ -235,9 +299,7 @@ export function createSequences({ state, source, parts }) {
|
||||
}
|
||||
|
||||
// New mesh samples may move the cones onto (or off) the road surface.
|
||||
state.context.meshSampler?.onSampled(() => {
|
||||
if (state.context.getSurface?.() === 'terrain') rerender();
|
||||
});
|
||||
const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled);
|
||||
|
||||
function setVisible(visible) {
|
||||
if (state.sequence.collection) state.sequence.collection.show = visible;
|
||||
@@ -246,6 +308,9 @@ export function createSequences({ state, source, parts }) {
|
||||
|
||||
function destroy(viewer) {
|
||||
state.sequence.abort?.abort();
|
||||
horizon.stop();
|
||||
drawn = nothingDrawn();
|
||||
stopMeshListener?.();
|
||||
const collection = state.sequence.collection;
|
||||
if (collection) {
|
||||
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { MESH_CELL_DEG } from '../../meshSampler.js';
|
||||
import { createSequences } from './sequences.js';
|
||||
|
||||
// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op.
|
||||
@@ -170,3 +172,155 @@ test('clearSelection cancels the lookup and uncolours the sequence', async () =>
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), [], 'the late answer draws nothing');
|
||||
});
|
||||
|
||||
/**
|
||||
* Terrain mode (Google 3D at street zoom): bare earth 10 m everywhere, a
|
||||
* mesh sampler whose samples the test sets and announces, and cones that
|
||||
* log every change made to them or their collection.
|
||||
*/
|
||||
function terrainSetup() {
|
||||
const context = setup();
|
||||
const { state } = context;
|
||||
const mesh = new Map();
|
||||
const cell = (lon, lat) =>
|
||||
`${Math.round(lon / MESH_CELL_DEG)},${Math.round(lat / MESH_CELL_DEG)}`;
|
||||
let announce = null;
|
||||
state.context.getSurface = () => 'terrain';
|
||||
state.context.groundCaster = {
|
||||
groundAt: () => 10,
|
||||
prepare: async () => true,
|
||||
};
|
||||
state.context.meshSampler = {
|
||||
request() {},
|
||||
meshAt: (lon, lat) => mesh.get(cell(lon, lat)),
|
||||
onSampled: (listener) => {
|
||||
announce = listener;
|
||||
return () => (announce = null);
|
||||
},
|
||||
};
|
||||
const changes = [];
|
||||
const items = [];
|
||||
state.sequence.collection = {
|
||||
items,
|
||||
show: true,
|
||||
add(options) {
|
||||
changes.push(['add', options.id]);
|
||||
const cone = new Proxy(options, {
|
||||
set(target, key, value) {
|
||||
changes.push([key, target.id]);
|
||||
target[key] = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
items.push(cone);
|
||||
return cone;
|
||||
},
|
||||
removeAll() {
|
||||
changes.push(['removeAll']);
|
||||
items.length = 0;
|
||||
},
|
||||
};
|
||||
// Created again, so it listens to this sampler.
|
||||
const sequences = createSequences({
|
||||
state,
|
||||
source: context.source,
|
||||
parts: { coverage: { recolorSequence() {} } },
|
||||
});
|
||||
/** Sample the mesh under a point, then announce it as the sampler does. */
|
||||
function sample(lon, lat, height) {
|
||||
const centre = [
|
||||
Math.round(lon / MESH_CELL_DEG) * MESH_CELL_DEG,
|
||||
Math.round(lat / MESH_CELL_DEG) * MESH_CELL_DEG,
|
||||
];
|
||||
mesh.set(cell(lon, lat), height);
|
||||
announce([centre]);
|
||||
}
|
||||
const heightOf = (cone) =>
|
||||
Cesium.Cartographic.fromCartesian(cone.position).height;
|
||||
return { ...context, sequences, changes, items, sample, heightOf };
|
||||
}
|
||||
|
||||
test('a mesh sample away from the selected sequence changes no cone (review IC8 P1)', async () => {
|
||||
const { source, sequences, changes, items, sample } = terrainSetup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A', 3));
|
||||
await settle();
|
||||
assert.equal(items.length, 3);
|
||||
changes.length = 0;
|
||||
sample(-121.3, 38.7, 12); // a street elsewhere in the tile
|
||||
assert.deepEqual(changes, [], 'nothing rebuilt or moved');
|
||||
});
|
||||
|
||||
test('a mesh sample under a cone moves that cone in place, and no other (review IC8 P1)', async () => {
|
||||
const { source, sequences, changes, items, sample, heightOf } =
|
||||
terrainSetup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A', 3));
|
||||
await settle();
|
||||
const before = items.map(heightOf);
|
||||
changes.length = 0;
|
||||
// The road under the second image is 2.5 m above the bare earth.
|
||||
sample(-121.489, 38.58, 12.5);
|
||||
assert.ok(
|
||||
changes.every(([, id]) => id === 'mly:img:A-1'),
|
||||
`only A-1 changed: ${JSON.stringify(changes)}`,
|
||||
);
|
||||
assert.ok(
|
||||
changes.some(([key]) => key === 'position'),
|
||||
'A-1 moved',
|
||||
);
|
||||
assert.equal(
|
||||
changes.some(([key]) => key === 'removeAll' || key === 'add'),
|
||||
false,
|
||||
'the collection is not rebuilt',
|
||||
);
|
||||
const after = items.map(heightOf);
|
||||
assert.ok(
|
||||
Math.abs(after[1] - 13.5) < 0.01,
|
||||
`A-1 stands on the road (${after[1]})`,
|
||||
);
|
||||
assert.deepEqual([after[0], after[2]], [before[0], before[2]]);
|
||||
});
|
||||
|
||||
test('cones behind the globe are hidden, and show again from the other side (review IC8 P2)', async () => {
|
||||
const { state, source, sequences } = setup();
|
||||
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
||||
const preRender = new Set();
|
||||
state.viewer = {
|
||||
camera: {
|
||||
get positionWC() {
|
||||
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
preRender: {
|
||||
addEventListener(listener) {
|
||||
preRender.add(listener);
|
||||
return () => preRender.delete(listener);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
sequences.select('A');
|
||||
// One image under the camera, one near its antipode.
|
||||
source.last('A').resolve([
|
||||
...records('A', 1),
|
||||
{
|
||||
id: 'A-far',
|
||||
geometry: { coordinates: [58.51, -38.58] },
|
||||
captured_at: 1_800_000_000_000,
|
||||
},
|
||||
]);
|
||||
await settle();
|
||||
const [near, far] = state.sequence.collection.items;
|
||||
assert.equal(near.show, true, 'the near side stays visible');
|
||||
assert.equal(far.show, false, 'the far side does not show through');
|
||||
// Fly round to the other hemisphere: the two swap.
|
||||
view.lon = 58.51;
|
||||
view.lat = -38.58;
|
||||
for (const listener of [...preRender]) listener();
|
||||
assert.equal(near.show, false);
|
||||
assert.equal(far.show, true);
|
||||
sequences.clearSelection();
|
||||
assert.equal(preRender.size, 0, 'the cull listener is gone');
|
||||
});
|
||||
@@ -9,6 +9,15 @@ function clampLat(lat) {
|
||||
return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
|
||||
}
|
||||
|
||||
/**
|
||||
* A longitude, or the difference of two, wrapped into [-180, 180]: the short
|
||||
* way round the date line. Values already in range come back unchanged.
|
||||
*/
|
||||
export function wrapLon(lon) {
|
||||
if (lon >= -180 && lon <= 180) return lon;
|
||||
return ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||
}
|
||||
|
||||
/** Fractional tile column of a longitude in [-180, 180] at zoom z. */
|
||||
function tileXAt(lon, z) {
|
||||
return ((lon + 180) / 360) * 2 ** z;
|
||||
@@ -27,8 +36,7 @@ export function lonToTileX(lon, z) {
|
||||
const n = 2 ** z;
|
||||
// 180° is the east edge of the last column, not the west edge of the first.
|
||||
if (lon >= 180) return n - 1;
|
||||
const wrapped = ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||
return Math.min(n - 1, Math.max(0, Math.floor(tileXAt(wrapped, z))));
|
||||
return Math.min(n - 1, Math.max(0, Math.floor(tileXAt(wrapLon(lon), z))));
|
||||
}
|
||||
|
||||
/** Tile row for a latitude at zoom z. */
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
tileBounds,
|
||||
tileLocalToLonLat,
|
||||
tilesForBbox,
|
||||
wrapLon,
|
||||
} from './tileMath.js';
|
||||
|
||||
test('lon/lat to tile matches the Sacramento reference tile', () => {
|
||||
@@ -125,3 +126,14 @@ test('a focus ranks tiles along the line of sight, not from the box centre (revi
|
||||
assert.ok(keys.includes(centre), 'the tile at the centre of the screen');
|
||||
assert.equal(ranked.total > 9, true);
|
||||
});
|
||||
|
||||
test('wrapLon wraps longitudes and their differences into [-180, 180]', () => {
|
||||
assert.equal(wrapLon(10.0005), 10.0005, 'in range: unchanged');
|
||||
assert.equal(wrapLon(180), 180);
|
||||
assert.equal(wrapLon(-180), -180);
|
||||
assert.ok(Math.abs(wrapLon(180.001) - -179.999) < 1e-9);
|
||||
assert.ok(Math.abs(wrapLon(-359.9998) - 0.0002) < 1e-9);
|
||||
assert.ok(Math.abs(wrapLon(359.9998) - -0.0002) < 1e-9);
|
||||
assert.ok(Math.abs(wrapLon(540.5) - -179.5) < 1e-9);
|
||||
assert.ok(Number.isNaN(wrapLon(NaN)));
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { wrapLon } from './tileMath.js';
|
||||
|
||||
/**
|
||||
* Approximate metres between two {lon, lat} points: equirectangular, good
|
||||
* for the street-scale distances the layer measures.
|
||||
* for the street-scale distances the layer measures. Measured the short way
|
||||
* round, so neighbours either side of ±180° are metres apart, not a world.
|
||||
*/
|
||||
export function metresBetween(a, b) {
|
||||
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
|
||||
return Math.hypot(
|
||||
(b.lon - a.lon) * 111_320 * Math.cos(lat),
|
||||
wrapLon(b.lon - a.lon) * 111_320 * Math.cos(lat),
|
||||
(b.lat - a.lat) * 110_540,
|
||||
);
|
||||
}
|
||||
@@ -248,3 +250,68 @@ export function viewFocus(
|
||||
}
|
||||
return { nadir, ahead };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide billboards and points behind the horizon, as the cyclones layer does.
|
||||
* Street Level's skip the depth test, so Google 3D and clouds never hide the
|
||||
* near side's; nothing else then stops the far hemisphere's drawing through
|
||||
* the globe, and a finite skip distance cannot, as the horizon's distance
|
||||
* moves with the camera. Re-culls on pre-render only once the camera moved,
|
||||
* and stops listening when there is nothing left to cull.
|
||||
* @param {{getViewer: () => object|null, items: () => Iterable<{position: object, show: boolean}>, onChange?: () => void}} options
|
||||
*/
|
||||
export function createHorizonCull({ getViewer, items, onChange }) {
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
|
||||
let from = null;
|
||||
let stopListening = null;
|
||||
|
||||
/** Show what is in front of the horizon, hide the rest; the count seen. */
|
||||
function cull(list, position) {
|
||||
occluder.cameraPosition = position;
|
||||
let seen = 0;
|
||||
let changed = false;
|
||||
for (const item of list) {
|
||||
seen++;
|
||||
const show = occluder.isPointVisible(item.position);
|
||||
if (item.show !== show) {
|
||||
item.show = show;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) onChange?.();
|
||||
return seen;
|
||||
}
|
||||
|
||||
/** Cull everything from the camera at `position`; the count seen. */
|
||||
function cullAll(position) {
|
||||
from = Cesium.Cartesian3.clone(position, from);
|
||||
return cull(items(), position);
|
||||
}
|
||||
|
||||
function onPreRender() {
|
||||
const position = getViewer()?.camera?.positionWC;
|
||||
if (!position || (from && Cesium.Cartesian3.equals(from, position))) return;
|
||||
if (!cullAll(position)) stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cull `added` (new or moved items), or everything, now; then everything
|
||||
* again whenever the camera moves.
|
||||
*/
|
||||
function update(added) {
|
||||
const viewer = getViewer();
|
||||
const position = viewer?.camera?.positionWC;
|
||||
if (position && !(added ? cull(added, position) : cullAll(position)))
|
||||
return;
|
||||
stopListening ||=
|
||||
viewer?.scene?.preRender?.addEventListener(onPreRender) || null;
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopListening?.();
|
||||
stopListening = null;
|
||||
from = null;
|
||||
}
|
||||
|
||||
return { update, stop };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
cameraHeightAboveGround,
|
||||
metresBetween,
|
||||
viewCentre,
|
||||
viewFocus,
|
||||
visibleBbox,
|
||||
@@ -146,3 +147,23 @@ test('looking at the horizon from eye height still boxes the ground around the c
|
||||
assert.ok(north - 38.5816 > 0.008 && 38.5816 - south > 0.008);
|
||||
assert.ok(east - -121.4944 > 0.008 && -121.4944 - west > 0.008);
|
||||
});
|
||||
|
||||
test('metresBetween measures the short way round the date line (review IC8 P2)', () => {
|
||||
const east = { lon: 179.9999, lat: 0 };
|
||||
const west = { lon: -179.9999, lat: 0 };
|
||||
// 0.0002° of longitude at the equator, not 40,000 km round the other way.
|
||||
assert.ok(Math.abs(metresBetween(east, west) - 22.264) < 1e-6);
|
||||
assert.ok(Math.abs(metresBetween(west, east) - 22.264) < 1e-6);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
metresBetween({ lon: 180, lat: 0 }, { lon: -180, lat: 0.0001 }) - 11.054,
|
||||
) < 1e-6,
|
||||
'the two names of the same meridian',
|
||||
);
|
||||
// Away from the date line nothing changes.
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
metresBetween({ lon: 10, lat: 0 }, { lon: 10.001, lat: 0 }) - 111.32,
|
||||
) < 1e-9,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user