diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index c78d25789..5b9b5246c 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -216,12 +216,14 @@ export function createCoverage({ state, source }) { } /** - * Fetch the terrain heights a tile's lines need, then redraw it cast. A - * cast that resolves nothing clears `castRequested`, so the next refresh - * retries instead of this looping. + * Fetch the terrain heights a tile's lines need, then redraw it cast. + * `castRequested` stops the redraw from casting again at once; a refresh + * `retry`s a tile whose lines are still draped (a failed or partial cast), + * and it is redrawn only when more of its lines can be cast. */ - function castTile(entry) { - if (entry.castRequested || entry.castAbort || !terrainMode()) return; + function castTile(entry, { retry = false } = {}) { + if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode()) + return; entry.castRequested = true; entry.castAbort = new AbortController(); const { signal } = entry.castAbort; @@ -233,9 +235,13 @@ export function createCoverage({ state, source }) { if (entry.castAbort?.signal === signal) entry.castAbort = null; const attached = [...state.coverage.tiles.values()].includes(entry); if (signal.aborted || !attached || !terrainMode()) return; - // Nothing new to cast: keep it draped until the next refresh. - if (!lines.some((coords) => caster.castLine(coords))) { - entry.castRequested = false; + // Nothing new to cast: keep what is drawn until the next refresh. + // Counted like buildSequencePrimitives: a cast line needs two points. + const castable = lines.filter( + (coords) => caster.castLine(coords)?.length >= 6, + ).length; + if (castable <= (entry.castLines || 0)) { + entry.castRequested = (entry.castLines || 0) > 0; return; } // A remesh may have cleared the flag while this was in flight. @@ -718,11 +724,12 @@ export function createCoverage({ state, source }) { for (const tile of tiles) loadTile(tile, kind); if (!state.coverage.pending.size) purgeStale(); // The camera moved: cells that were out of the sampler's range may not be, - // and a tile left draped by a failed or dropped cast gets another try. + // and lines left draped by a failed, partial or dropped cast get another try. if (terrainMode()) - for (const entry of state.coverage.tiles.values()) + for (const entry of state.coverage.tiles.values()) { if (entry.castRequested) requestMesh(entry); - else if (entry.drapedLines) castTile(entry); + if (entry.drapedLines) castTile(entry, { retry: true }); + } notify(); } diff --git a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs index 1dc96d14d..26c6b13fc 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs @@ -930,3 +930,60 @@ test('a tile that fails before the terrain table loads is reported at once, neve process.off('unhandledRejection', onUnhandled); } }); + +test('lines a partial terrain lookup left draped are cast once the terrain recovers', async () => { + // Two lines: the west one's heights arrive, the east one's fail at first. + let eastUp = false; + let prepares = 0; + const groundCaster = { + prepareLines: async () => { + prepares++; + return eastUp; + }, + castLine: (coords) => + coords[0][0] < centreLon || eastUp + ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) + : null, + }; + const { state, source, coverage, centre } = setup({ + surface: 'terrain', + groundCaster, + }); + const centreLon = centre.lon; + const line = (from, to) => [ + [ + [from, centre.lat], + [to, centre.lat], + ], + ]; + coverage.refresh(); + source.calls[0].resolve( + encodeCoverageTile(centre.tile, { + sequences: [ + { id: 'west', parts: line(centre.lon - 0.002, centre.lon - 0.001) }, + { id: 'east', parts: line(centre.lon + 0.001, centre.lon + 0.002) }, + ], + }), + ); + await settle(); + await settle(); + const [entry] = state.coverage.tiles.values(); + assert.equal(prepares, 1); + assert.deepEqual([entry.castLines, entry.drapedLines], [1, 1], 'partial'); + await settle(); + assert.equal(prepares, 1, 'no retry loop while the lookup fails'); + + // A refresh while still failing asks again but rebuilds nothing. + const drawn = entry.primitives; + coverage.refresh(); + await settle(); + assert.equal(prepares, 2); + assert.equal(entry.primitives, drawn, 'no rebuild without new heights'); + + eastUp = true; // the terrain proxy recovers + coverage.refresh(); + await settle(); + assert.equal(prepares, 3, 'the next refresh tries the draped line again'); + assert.deepEqual([entry.castLines, entry.drapedLines], [2, 0], 'all cast'); + coverage.clear(); +});