diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index fd9a9cec6..ce4d1bf86 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -552,12 +552,21 @@ export function createFlow({ state: layerState, services, parts, source }) { paint(); } paint(true); - if (current()) layerState._surfacePending = prepared.pending.length; + // A completed-mesh pass with no new heights cannot improve this view. + // Keep the roads in _surfaceInput for a later view or surface change, + // without leaving offscreen or unavailable stations loading forever. + const retryPending = + prepared.pending.length > 0 && + (!refining || + !trafficSurfaceReady(engine) || + prepared.metrics.resolvedSamples > 0); + if (current()) + layerState._surfacePending = retryPending ? prepared.pending.length : 0; if ( current() && !preview && ((!refining && !trafficSurfaceReady(engine)) || - prepared.pending.length || + retryPending || (!refining && ready.some((road) => road.surfaceSettled === false))) ) { layerState._surfaceRefineRemove?.(); @@ -579,7 +588,7 @@ export function createFlow({ state: layerState, services, parts, source }) { stop(); // Revalidate only local samples taken at a coarser rendered LOD. This // background upgrade never holds road acquisition or first paint; - // missing samples keep retrying while refined heights stay cached. + // missing samples retry while heights make progress and stay cached. layerState._surfaceRefining = true; applyFlowThenRender( roads, @@ -600,7 +609,10 @@ export function createFlow({ state: layerState, services, parts, source }) { layerState._surfaceRefining = false; }); }); - const timer = setTimeout(stop, 10000); + const timer = setTimeout(() => { + stop(); + if (current()) layerState._surfacePending = 0; + }, 10000); layerState._surfaceRefineRemove = stop; engine.render.request('traffic-surface'); } diff --git a/src/layers/traffic/handoff.test.mjs b/src/layers/traffic/handoff.test.mjs index 8e5e53b21..eb000ef4b 100644 --- a/src/layers/traffic/handoff.test.mjs +++ b/src/layers/traffic/handoff.test.mjs @@ -241,6 +241,101 @@ test('a still camera drains pending surface samples across bounded passes using assert.equal(engine.fake.renderRequests, finished, 'settled work is idle'); }); +test('a completed surface defers unavailable roads until the next view instead of refining forever', async (t) => { + const surface = incomingGlobe(t); + surface.ready = true; + surface.frame(); + const { engine } = surface; + const { state, flow } = fixture(surface); + t.after(() => { + state._activeFetchAbort.abort(); + state._surfaceRefineRemove?.(); + }); + const roads = [road(0, GROUND), road(0.008, GROUND)]; + let missing = true; + engine.fake.script('terrainHeight', (point) => + missing && geo.math.toDegrees(point.latitude) > LAT + 0.004 + ? undefined + : GROUND, + ); + await flow.applyFlowThenRender( + roads, + { ...bounds, coverage }, + 1, + 1800, + 'Loaded full', + ); + let rendered = engine.fake.renderRequests; + await settle(() => { + if (engine.fake.renderRequests > rendered || state._surfaceRefineRemove) { + rendered = engine.fake.renderRequests; + surface.frame(); + } + return !state._surfacePending && !state._surfaceRefining; + }); + assert.equal( + state._surfacePending, + 0, + 'unavailable roads are not outstanding work', + ); + assert.equal(state._surfaceRefining, false); + assert.equal(state._surfaceRefineRemove, null); + assert.equal(roads[1].surfaceReady, false, 'no unmeasured road is displayed'); + const finished = engine.fake.renderRequests; + surface.frame(); + await nextTask(); + assert.equal( + engine.fake.renderRequests, + finished, + 'the completed view is idle', + ); + + missing = false; + await flow.applyFlowThenRender( + roads, + { ...bounds, coverage }, + 1, + 1800, + 'Loaded full', + ); + assert.equal( + roads[1].surfaceReady, + true, + 'another view retries the retained road', + ); +}); + +test('the surface wait deadline defers pending roads when the mesh never settles', async (t) => { + const surface = incomingGlobe(t); + surface.frame(); + const { state, flow } = fixture(surface); + t.after(() => { + state._activeFetchAbort.abort(); + state._surfaceRefineRemove?.(); + }); + const schedule = globalThis.setTimeout; + let deadline; + t.mock.method(globalThis, 'setTimeout', (callback, delay, ...args) => { + if (delay === 10000) deadline = callback; + return schedule(callback, delay, ...args); + }); + surface.engine.fake.script('terrainHeight', (point) => + geo.math.toDegrees(point.latitude) > LAT + 0.004 ? undefined : GROUND, + ); + await flow.applyFlowThenRender( + [road(0, GROUND), road(0.008, GROUND)], + { ...bounds, coverage }, + 1, + 1800, + 'Loaded full', + ); + assert.equal(state._surfacePending, 1); + assert.equal(typeof deadline, 'function'); + deadline(); + assert.equal(state._surfacePending, 0); + assert.equal(state._surfaceRefineRemove, null); +}); + test('resuming the viewport republishes the retained roads as a pass that can still refine', () => { const labels = []; const state = createState({ services: {} }); diff --git a/src/layers/traffic/surface.js b/src/layers/traffic/surface.js index 8585d149d..76c6413d1 100644 --- a/src/layers/traffic/surface.js +++ b/src/layers/traffic/surface.js @@ -258,6 +258,7 @@ export async function prepareRoadSurfaces( const metrics = { sampleCount: 0, sampleMs: 0, + resolvedSamples: 0, cacheHits: 0, depthHits: 0, roads: roads.length, @@ -341,6 +342,7 @@ export async function prepareRoadSurfaces( height = validHeight(floor) ? floor : 0; } } else { + metrics.resolvedSamples++; height = validHeight(floor) ? Math.max(sampled, floor) : sampled; cache.heights.delete(key); cache.heights.set(key, { diff --git a/src/layers/traffic/surface.test.mjs b/src/layers/traffic/surface.test.mjs index 21211619f..ce4d95e69 100644 --- a/src/layers/traffic/surface.test.mjs +++ b/src/layers/traffic/surface.test.mjs @@ -89,6 +89,7 @@ test('per-vertex mesh heights follow terrain, reject invalid samples, and leave onMetrics: (value) => (metrics = value), }); assert.equal(metrics.sampleCount, 5); + assert.equal(metrics.resolvedSamples, 2); assert.equal(prepared.pending.length, 1); assert.equal(samples, 5); const actual = r.waypoints.map((p) => Math.round(heightOf(p))); @@ -108,7 +109,12 @@ test('per-vertex mesh heights follow terrain, reject invalid samples, and leave picks++; return 75; }); - await prepareRoadSurfaces([r], engine, ground, []); + const cached = await prepareRoadSurfaces([r], engine, ground, []); + assert.equal( + cached.metrics.resolvedSamples, + 0, + 'cache hits are not progress', + ); assert.equal(picks, 0, 'validated coordinate heights survive cache hits'); const abort = new AbortController(); abort.abort();