mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04: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
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user