From ce3f66a8377d34d7c99285721706b82767e31ebf Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 15:54:21 +0100 Subject: [PATCH] =?UTF-8?q?fix(street-level):=20IC8=20round=203=20?= =?UTF-8?q?=E2=80=94=20no=20blink=20when=20the=20point=20budget=20is=20re-?= =?UTF-8?q?shared?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/layers/streetLevel/lineCoverage.js | 52 +++++++---- src/layers/streetLevel/lineCoverage.test.mjs | 95 ++++++++++++++++++++ 2 files changed, 131 insertions(+), 16 deletions(-) diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 50a81cfe2..78c9b36da 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -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; diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index e7b8b693e..db89e8143 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -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); +});