fix(street-level): IC8 round 3 — no blink when the point budget is re-shared

The round-3 verification found the shared point budget rebuilding a dense
tile by removing its lines before the new ones were built, so central
Paris vanished for 1–2 s after every pan:
- Tiles redrawn for a new share, or for a selected line the cap had left
  out, go through the swap path: the old lines stay until the new ones
  are ready.
- A share must move by 10% to redraw a tile (a 1.6% change rebuilt it).
- The selected line, admitted past the cap, is not counted against it,
  so its tile no longer redraws on every settle.
- A selection the imagery filter hides is not redrawn for.
- Shares are recomputed when a refresh ends loading without a tile
  finishing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-14 15:54:21 +01:00
1 parent da0d370fa3
commit ce3f66a837
2 files changed
+131 -16

No files matched your search

+36 -16
View File
@@ -12,6 +12,8 @@ import { densifyLine, MESH_DENSIFY_DEG } from './groundCast.js';
import { MESH_SAMPLE_RADIUS_M } from './meshSampler.js';
import { tileBounds, tilesForBbox } from './tileMath.js';
/** A tile's shared point cap must move by this much to redraw it. */
const SHARE_HYSTERESIS = 0.1;
/** Draped lines stay at most this long while a tile's cast lines build. */
export const SWAP_MAX_WAIT_MS = 4000;
/** Tiles touched by new mesh samples are redrawn at most this often. */
@@ -164,9 +166,9 @@ export function createLineCoverage({
const size = pointsOf(sequence);
demand += size;
// The selected sequence is always drawn: its highlight is the answer
// to a click, whatever the caps leave out.
// to a click, whatever the caps leave out. It is outside the cap, so
// it is not counted against it.
if (sequence.id === selectedId) {
points += size;
drawn.push(sequence);
continue;
}
@@ -204,32 +206,45 @@ export function createLineCoverage({
left -= cap;
const before = entry.pointCap ?? perTilePointCap;
entry.pointCap = cap;
// Only a change that alters what is drawn: more room for a tile that
// had lines left out, or less than it draws now.
if (
(cap > before && entry.pointsDrawn < entry.pointDemand) ||
cap < entry.pointsDrawn
)
changed.push(entry);
// Only a change that alters what is drawn, by enough to be worth a
// rebuild: more room for a tile that had lines left out, or well
// under what it draws now (a small overshoot stays until the next
// natural redraw).
const grows =
cap > before * (1 + SHARE_HYSTERESIS) &&
entry.pointsDrawn < entry.pointDemand;
const shrinks = cap < entry.pointsDrawn * (1 - SHARE_HYSTERESIS);
if (grows || shrinks) changed.push(entry);
});
return changed;
}
/** Once loading settles: redraw the tiles the shared budget changes. */
/**
* Once loading settles: redraw the tiles the shared budget changes,
* keeping their old lines until the new ones are ready (no blink).
*/
function shareBudget() {
const changed = shareCaps();
for (const entry of changed) redrawEntry(entry);
for (const entry of changed) redrawEntry(entry, { swap: true });
if (changed.length) {
requestRender();
notify();
}
}
/** Draw one tile again from its decoded lines (caps or selection changed). */
function redrawEntry(entry) {
/**
* Draw one tile again from its decoded lines (filter, caps or selection
* changed). With `swap`, its old lines stay until the new ones are ready.
*/
function redrawEntry(entry, { swap = false } = {}) {
cancelCast(entry);
entry.castRequested = false;
entry.meshParts = null;
if (swap && entry.kind === 'sequence' && entry.primitives?.length) {
finishSwap(entry);
redrawTile(entry);
return;
}
detachPrimitive(entry);
attachPrimitive(entry);
}
@@ -825,7 +840,10 @@ export function createLineCoverage({
state.coverage.pending.delete(key);
}
for (const tile of tiles) loadTile(tile, kind);
if (!state.coverage.pending.size) purgeStale();
if (!state.coverage.pending.size) {
purgeStale();
shareBudget();
}
// The camera moved: cells that were out of the sampler's range may not be,
// and lines left draped by a failed, partial or dropped cast get another try.
if (terrainMode())
@@ -943,9 +961,11 @@ export function createLineCoverage({
if (
entry.kind === 'sequence' &&
!entry.sequences.has(id) &&
entry.sequenceList.some((sequence) => sequence.id === id)
entry.sequenceList.some(
(sequence) => sequence.id === id && passes(sequence, filter()),
)
)
redrawEntry(entry);
redrawEntry(entry, { swap: true });
for (const entry of drawnEntries()) {
const sequence = entry.sequences.get(id);
if (!sequence) continue;
@@ -338,3 +338,98 @@ test('a selected sequence the budget left out is drawn, highlighted', async (t)
coverage.recolorSequence('short', true);
assert.deepEqual([...tile.sequences.keys()].sort(), ['long', 'short']);
});
/** Count the line primitives a coverage adds to the scene. */
function countAdds(viewer) {
const counts = { added: 0 };
for (const collection of [
viewer.scene.groundPrimitives,
viewer.scene.primitives,
]) {
const add = collection.add.bind(collection);
collection.add = (primitive) => {
counts.added++;
return add(primitive);
};
}
return counts;
}
test('a tile redrawn for a new share keeps its old lines until the new ones are ready', async (t) => {
const tuning = {
...TUNING,
maxTiles: 9,
maxSequences: 900,
maxPoints: 90,
keepOrder: true,
};
const { state, coverage, viewer } = setup(t, {
zoomFor: () => ({ zoom: 18, kind: 'sequence' }),
tuning,
decoded: (centre, tile) => {
const dense = tile.x % 2 === 0 && tile.y % 2 === 0;
return {
sequences: dense
? Array.from({ length: 30 }, (_, i) =>
pointsLine(`d${tile.x}/${tile.y}/${i}`, 5, tile, centre),
)
: [pointsLine(`s${tile.x}/${tile.y}`, 2, tile, centre)],
overview: [],
};
},
});
coverage.refresh();
assert.ok(
await until(
() => state.coverage.tiles.size > 1 && !state.coverage.pending.size,
),
);
const redrawn = [...state.coverage.tiles.values()].filter(
(entry) => entry.swap,
);
assert.ok(redrawn.length > 0, 'a dense tile took a bigger share');
for (const entry of redrawn)
for (const { primitive } of entry.swap.old)
assert.ok(
viewer.scene.groundPrimitives.items.has(primitive) ||
viewer.scene.primitives.items.has(primitive),
'the old lines are still on the globe while the new ones build',
);
});
test('a selected line outside the cap does not make its tile redraw on every settle, nor does one the filter hides', async (t) => {
let pano = 'all';
const { state, coverage, viewer } = setup(t, {
tuning: {
...TUNING,
maxTiles: 1,
maxSequences: 10,
maxPoints: 5,
keepOrder: true,
},
decoded: (centre, tile) => ({
sequences: [
pointsLine('long', 5, tile, centre),
{ ...pointsLine('short', 3, tile, centre), isPano: true },
],
overview: [],
}),
});
state.context.getFilter = () => ({ pano, sinceMs: null });
coverage.refresh();
assert.ok(await until(() => state.coverage.tiles.size === 1));
state.sequence.selectedId = 'short';
coverage.recolorSequence('short', true);
const counts = countAdds(viewer);
for (let i = 0; i < 3; i++) {
coverage.refresh();
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.equal(counts.added, 0, 'settles redraw nothing');
// A selection the filter hides is not drawn, so it is not redrawn for.
pano = 'flat';
coverage.rebuild();
const after = countAdds(viewer);
for (let i = 0; i < 3; i++) coverage.recolorSequence('short', true);
assert.equal(after.added, 0);
});