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:
Said TurkseverandClaude Opus 5.5 committed 2026-10-06 15:16:57 +01:00
1 parent 09fc10756c
commit bd97d38a99
15 files changed
+1085 -164

No files matched your search

+131
View File
@@ -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();
});