diff --git a/src/layers/streetLevel/groundCast.js b/src/layers/streetLevel/groundCast.js index 64a21280e..d93683359 100644 --- a/src/layers/streetLevel/groundCast.js +++ b/src/layers/streetLevel/groundCast.js @@ -22,7 +22,7 @@ export const GROUND_CAST_DENSIFY_DEG = 0.0005; export const GROUND_CAST_LIFT_M = 2; /** Grid corners one prepare call may request; more stays draped. */ export const GROUND_CAST_MAX_CORNERS = 1024; -/** Cached grid corners before the cache is dropped and refilled. */ +/** Cached grid corners before the oldest are dropped to make room. */ const GROUND_CAST_CACHE_MAX = 50_000; /** @@ -170,9 +170,13 @@ export function createGroundCaster({ async function resolve(points, signal) { if (signal?.aborted) return false; - // Make room first: clearing after counting would drop corners this - // request already treated as cached. - if (heights.size + maxCorners > cacheMax) heights.clear(); + // Make room first: evicting after counting would drop corners this + // request already treated as cached. Oldest first (a Map keeps insertion + // order), so the tiles cast last, likely still on screen, keep theirs. + for (const key of heights.keys()) { + if (heights.size + maxCorners <= cacheMax) break; + heights.delete(key); + } const missing = missingCorners(points); if (!missing.length) return true; if (missing.length > maxCorners) return false; diff --git a/src/layers/streetLevel/groundCast.test.mjs b/src/layers/streetLevel/groundCast.test.mjs index e53307d74..5eaf5bd84 100644 --- a/src/layers/streetLevel/groundCast.test.mjs +++ b/src/layers/streetLevel/groundCast.test.mjs @@ -239,6 +239,27 @@ test('a full cache is cleared before a request counts its corners, so the reques assert.ok(caster.castLine(line), 'the whole line casts'); }); +test('a full cache drops its oldest corners, not every corner', async () => { + const caster = createGroundCaster({ + terrain: fakeTerrain(), + step: 0.001, + maxCorners: 4, + cacheMax: 12, + }); + // Three cells apart from each other, four corners each: the cache is full. + const cells = [10.0005, 10.0105, 10.0205, 10.0305].map((lon) => [ + lon, + 50.0005, + ]); + for (const cell of cells.slice(0, 3)) + assert.equal(await caster.prepare([cell]), true); + // A fourth makes room by dropping the first cell only. + assert.equal(await caster.prepare([cells[3]]), true); + assert.equal(caster.heightAt(...cells[0]), null, 'oldest dropped'); + for (const cell of cells.slice(1)) + assert.notEqual(caster.heightAt(...cell), null, 'newer cells kept'); +}); + test('cancelling a prepare cancels its terrain request', async () => { const seen = []; const caster = createGroundCaster({ diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index 139fc0e73..edaaf1fa5 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -115,23 +115,36 @@ export function createCoverage({ state, source }) { ); } + /** + * The sequences a tile draws: newest first, past the imagery filter, then + * capped. Capping before the filter could leave a dense tile of newer flat + * captures with no 360° lines at all. + */ + function drawnSequences(entry) { + const drawn = []; + for (const sequence of entry.sequenceList) { + if (drawn.length >= PER_TILE_SEQUENCE_CAP) break; + if (passesImageryFilter(sequence, filter())) drawn.push(sequence); + } + return drawn; + } + /** * Primitives for a tile's sequences, in draw batches: ground primitives * draped on the globe, plus (in terrain mode) plain polylines at the cast * heights for every part whose heights are cached. Each part of a sequence - * with a capture gap is its own line. + * with a capture gap is its own line. Each batch remembers the selection + * its colours were built with. */ function buildSequencePrimitives(sequences) { const ground = terrainMode() ? state.context.groundCaster : null; const meshAt = ground ? state.context.meshSampler?.meshAt : undefined; + const selectedId = state.sequence.selectedId ?? null; const draped = []; const cast = []; - let drawn = 0; for (const sequence of sequences) { - if (!passesImageryFilter(sequence, filter())) continue; - drawn++; const color = Cesium.ColorGeometryInstanceAttribute.fromColor( - sequenceColor({ selected: sequence.id === state.sequence.selectedId }), + sequenceColor({ selected: sequence.id === selectedId }), ); const ids = partIds(sequence); sequence.parts.forEach((coordinates, index) => { @@ -169,6 +182,7 @@ export function createCoverage({ state, source }) { for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH) primitives.push({ onGround: true, + selectedId, primitive: new Cesium.GroundPolylinePrimitive({ geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), appearance: new Cesium.PolylineColorAppearance(), @@ -180,6 +194,7 @@ export function createCoverage({ state, source }) { for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH) primitives.push({ onGround: false, + selectedId, primitive: new Cesium.Primitive({ geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), appearance: new Cesium.PolylineColorAppearance({ translucent: true }), @@ -187,27 +202,36 @@ export function createCoverage({ state, source }) { allowPicking: true, }), }); - return { primitives, count: drawn, draped }; + return { primitives, draped, cast: cast.length }; } /** * Fetch the terrain heights a tile's lines need, then redraw the tile cast. - * Runs once per tile entry; unresolved lines stay draped. + * `castRequested` holds while the tile's castable lines are cast; a cast + * that resolves nothing (terrain proxy down, tile too big) clears it, so + * the next refresh tries again rather than this retrying in a loop. */ function castTile(entry) { - if (entry.castRequested || !terrainMode()) return; + // `castAbort` is set while a cast is in flight. + if (entry.castRequested || entry.castAbort || !terrainMode()) return; entry.castRequested = true; entry.castAbort = new AbortController(); const { signal } = entry.castAbort; - const lines = entry.sequenceList - .filter((sequence) => passesImageryFilter(sequence, filter())) - .flatMap((sequence) => sequence.parts); + const lines = [...entry.sequences.values()].flatMap( + (sequence) => sequence.parts, + ); const caster = state.context.groundCaster; caster.prepareLines(lines, { signal }).then(() => { + 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 (terrain proxy down, tile too big): keep it draped. - if (!lines.some((coords) => caster.castLine(coords))) return; + // Nothing new to cast: keep it draped until the next refresh. + if (!lines.some((coords) => caster.castLine(coords))) { + entry.castRequested = false; + return; + } + // A remesh may have cleared the flag while this was in flight. + entry.castRequested = true; // Keep the draped lines until the cast ones are built, so nothing blinks. const previous = entry.primitives; entry.primitives = []; @@ -217,11 +241,11 @@ export function createCoverage({ state, source }) { }); } - /** The mesh cells under a tile's lines, built once per tile. */ + /** The mesh cells under a tile's drawn lines, built once per filter. */ function meshCells(entry) { if (entry.meshCells) return entry.meshCells; const cells = new Map(); - for (const sequence of entry.sequenceList) + for (const sequence of entry.sequences.values()) for (const [lon, lat] of sequence.parts.flatMap((part) => densifyLine(part, MESH_DENSIFY_DEG), )) @@ -342,37 +366,101 @@ export function createCoverage({ state, source }) { const green = Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha( 0.85, ); - let count = 0; + const drawn = []; for (const point of points) { if (!passesImageryFilter(point, filter())) continue; - collection.add({ - position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat), - color: green, - pixelSize: COVERAGE_OVERVIEW_POINT_PX, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }); - count++; + drawn.push( + collection.add({ + position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat), + color: green, + pixelSize: COVERAGE_OVERVIEW_POINT_PX, + // Google 3D terrain and clouds must not hide the near side's dots; + // `cullHorizon` hides the far side's, which this lets through. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }), + ); } - return { collection, count }; + return { collection, points: drawn }; + } + + /** + * Hide the overview points behind the horizon, as the cyclones layer does. + * They skip the depth test, so nothing else stops the far hemisphere's + * coverage drawing over this one at whole-earth zooms; a finite skip + * distance cannot do it, as the horizon's distance moves with the camera. + */ + function cullHorizon() { + const position = state.viewer?.camera?.positionWC; + if (!position) return; + const entries = [ + ...state.coverage.tiles.values(), + ...state.coverage.stale.values(), + ].filter((entry) => entry.overviewPoints); + if (!entries.length) { + stopHorizonCull(); + return; + } + const from = state.coverage.cullFrom; + if (from && Cesium.Cartesian3.equals(from, position)) return; + state.coverage.cullFrom = Cesium.Cartesian3.clone(position, from); + let changed = false; + for (const entry of entries) + if (cullPoints(entry.overviewPoints, position)) changed = true; + if (changed) requestRender(); + } + + /** Show the points in front of the horizon seen from `position`, hide the rest. */ + function cullPoints(points, position) { + const occluder = (state.coverage.occluder ||= + new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84)); + occluder.cameraPosition = position; + let changed = false; + for (const point of points) { + const show = occluder.isPointVisible(point.position); + if (point.show !== show) { + point.show = show; + changed = true; + } + } + return changed; + } + + function stopHorizonCull() { + state.coverage.stopHorizonCull?.(); + state.coverage.stopHorizonCull = null; + state.coverage.cullFrom = null; } function attachPrimitive(entry) { const scene = state.viewer?.scene; if (!scene) return; if (entry.kind === 'sequence') { - const { primitives, count, draped } = buildSequencePrimitives( - entry.sequenceList, - ); + // Lookup, picking and counts follow what is drawn, not the whole tile. + const sequences = drawnSequences(entry); + entry.sequences = new Map(sequences.map((s) => [s.id, s])); + const { primitives, draped, cast } = buildSequencePrimitives(sequences); entry.primitives = primitives; - entry.count = count; + entry.count = sequences.length; for (const { primitive, onGround } of primitives) (onGround ? scene.groundPrimitives : scene.primitives).add(primitive); + // Fewer lines cast than last time: the caster has dropped heights this + // tile used (a full cache), so it must be cast again. + if (cast < (entry.castLines || 0)) entry.castRequested = false; + entry.castLines = cast; + entry.drapedLines = draped.length; if (draped.length) castTile(entry); + watchSelection(); } else { - const { collection, count } = buildOverviewCollection(entry.points); + const { collection, points } = buildOverviewCollection(entry.points); entry.primitive = collection; - entry.count = count; + entry.overviewPoints = points; + entry.count = points.length; scene.primitives.add(collection); + // Cull the new points now, then every frame the camera has moved. + const camera = state.viewer.camera?.positionWC; + if (camera) cullPoints(points, camera); + state.coverage.stopHorizonCull ||= + scene.preRender?.addEventListener(cullHorizon) || null; } } @@ -401,6 +489,7 @@ export function createCoverage({ state, source }) { /* already gone */ } entry.primitive = null; + entry.overviewPoints = null; } } @@ -458,12 +547,11 @@ export function createCoverage({ state, source }) { north: bounds.north + pad, }; if (kind === 'sequence') { - // Newest first, capped per tile so a dense city stays within budget. - const sequences = decoded.sequences - .sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0)) - .slice(0, PER_TILE_SEQUENCE_CAP); - entry.sequenceList = sequences; - entry.sequences = new Map(sequences.map((s) => [s.id, s])); + // Newest first; the per-tile cap applies to what passes the filter. + entry.sequenceList = decoded.sequences.sort( + (a, b) => (b.capturedAt || 0) - (a.capturedAt || 0), + ); + entry.sequences = new Map(); entry.total = decoded.sequences.length; } else { entry.points = decoded.overview; @@ -642,10 +730,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. + // 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. if (terrainMode()) for (const entry of state.coverage.tiles.values()) if (entry.castRequested) requestMesh(entry); + else if (entry.drapedLines) castTile(entry); notify(); } @@ -687,6 +777,8 @@ export function createCoverage({ state, source }) { clearTimeout(state.coverage.remeshTimer); state.coverage.remeshTimer = null; state.coverage.remeshDirty.clear(); + stopSelectionWatch(); + stopHorizonCull(); requestRender(); } @@ -696,6 +788,7 @@ export function createCoverage({ state, source }) { for (const entry of state.coverage.tiles.values()) { cancelCast(entry); entry.castRequested = false; + entry.meshCells = null; detachPrimitive(entry); attachPrimitive(entry); } @@ -723,31 +816,83 @@ export function createCoverage({ state, source }) { return null; } + /** Recolour every part of a sequence in one ready primitive. */ + function recolorInstances(primitive, sequence, selected) { + const value = sequenceColor({ selected }); + for (const instanceId of partIds(sequence)) { + try { + const attributes = primitive.getGeometryInstanceAttributes(instanceId); + if (attributes) + attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue( + value, + attributes.color, + ); + } catch { + /* instance not in this primitive */ + } + } + } + /** Recolour one sequence, every part of it, in place (selection highlight). */ function recolorSequence(id, selected) { - const value = sequenceColor({ selected }); for (const entry of state.coverage.tiles.values()) { const sequence = entry.sequences.get(id); if (!sequence) continue; - for (const instanceId of partIds(sequence)) - for (const { primitive } of entry.primitives || []) { - if (!primitive.ready) continue; - try { - const attributes = - primitive.getGeometryInstanceAttributes(instanceId); - if (attributes) - attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue( - value, - attributes.color, - ); - } catch { - /* instance not in this primitive */ - } - } + for (const record of entry.primitives || []) { + // Still building: `syncSelection` catches it up once it is ready. + if (!record.primitive.ready) continue; + recolorInstances(record.primitive, sequence, selected); + if (selected) record.selectedId = id; + else if (record.selectedId === id) record.selectedId = null; + } } requestRender(); } + /** + * Bring a ready primitive's highlight up to the current selection, when + * it was built with another one (or none). True when it changed. + */ + function syncSelection(entry, record) { + const current = state.sequence.selectedId ?? null; + if (record.selectedId === current || !record.primitive.ready) return false; + const previous = + record.selectedId && entry.sequences.get(record.selectedId); + if (previous) recolorInstances(record.primitive, previous, false); + const next = current && entry.sequences.get(current); + if (next) recolorInstances(record.primitive, next, true); + record.selectedId = current; + return true; + } + + /** + * Colours are baked in when a tile's lines are built, and a primitive + * cannot be recoloured until it is ready. Cast swaps and remeshes rebuild + * tiles all the time, so while any primitive is building, catch each one + * up with the selection made or cleared meanwhile as it becomes ready. + */ + function watchSelection() { + const scene = state.viewer?.scene; + if (state.coverage.stopSelectionWatch || !scene?.postRender) return; + const stop = scene.postRender.addEventListener(() => { + let building = false; + let changed = false; + for (const entry of state.coverage.tiles.values()) + for (const record of entry.primitives || []) { + if (!record.primitive.ready) building = true; + else if (syncSelection(entry, record)) changed = true; + } + if (changed) requestRender(); + if (!building) stopSelectionWatch(); + }); + state.coverage.stopSelectionWatch = stop; + } + + function stopSelectionWatch() { + state.coverage.stopSelectionWatch?.(); + state.coverage.stopSelectionWatch = null; + } + function sequenceCount() { let count = 0; for (const entry of state.coverage.tiles.values()) count += entry.count; diff --git a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs index 34335893d..2aefafd19 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs @@ -3,6 +3,12 @@ import test from 'node:test'; import * as Cesium from 'cesium'; import { createCoverage } from './coverage.js'; import { encodeCoverageTile } from './coverageFixture.mjs'; +import { + COLORS, + COVERAGE_MAX_SEQUENCES, + COVERAGE_MAX_TILES, +} from './policy.js'; +import { createGroundCaster } from '../../groundCast.js'; import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js'; const RAD = Math.PI / 180; @@ -42,9 +48,11 @@ function tileCentre(lon, lat) { /** A viewer looking straight down on a 0.004° square around `view`. */ function fakeViewer(view) { const postRender = new Set(); + const preRender = new Set(); return { view, postRender, + preRender, scene: { canvas: { clientWidth: 100, clientHeight: 100 }, globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 }, @@ -56,6 +64,12 @@ function fakeViewer(view) { return () => postRender.delete(listener); }, }, + preRender: { + addEventListener(listener) { + preRender.add(listener); + return () => preRender.delete(listener); + }, + }, }, camera: { get positionCartographic() { @@ -89,7 +103,11 @@ function deferredSource() { }; } -function setup({ surface = 'draped', groundCaster = null } = {}) { +function setup({ + surface = 'draped', + groundCaster = null, + meshSampler = null, +} = {}) { const centre = tileCentre(-121.4944, 38.5816); const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 }); const source = deferredSource(); @@ -103,7 +121,7 @@ function setup({ surface = 'draped', groundCaster = null } = {}) { notify() {}, getSurface: () => surface, groundCaster, - meshSampler: null, + meshSampler, }, coverage: { zoom: null, @@ -140,7 +158,12 @@ function setup({ surface = 'draped', groundCaster = null } = {}) { const onGlobe = () => viewer.scene.groundPrimitives.items.size + viewer.scene.primitives.items.size; - return { viewer, source, state, coverage, bytes, tileKey, onGlobe }; + return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre }; +} + +/** Run one frame's listeners, as Cesium's render loop would. */ +function frame(listeners) { + for (const listener of [...listeners]) listener(); } test('a superseded tile request cannot strand lines on the globe (review P0 #2)', async () => { @@ -324,3 +347,214 @@ test('a rate limit keeps the drawn tiles and asks again once the wait is over (r coverage.clear(); coverage.resetErrors(); }); + +test('overview points behind the horizon are hidden, not drawn through the globe', async () => { + const { viewer, source, state, coverage } = setup(); + const { view } = viewer; + view.height = 20_000_000; + Object.defineProperty(viewer.camera, 'positionWC', { + get: () => Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height), + }); + coverage.refresh(); + // One dot under the camera (Sacramento), one near its antipode. + source.calls[0].resolve( + encodeCoverageTile( + { x: 0, y: 0, z: 0 }, + { + overview: [ + { id: 'near', lon: -121.5, lat: 38.6 }, + { id: 'far', lon: 58.5, lat: -38.6 }, + ], + }, + ), + ); + await settle(); + const [entry] = state.coverage.tiles.values(); + const points = [0, 1].map((i) => entry.primitive.get(i)); + const west = (point) => + Cesium.Cartographic.fromCartesian(point.position).longitude < 0; + const near = points.find(west); + const far = points.find((point) => !west(point)); + frame(viewer.preRender); + 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.5; + view.lat = -38.6; + frame(viewer.preRender); + assert.equal(near.show, false); + assert.equal(far.show, true); + coverage.clear(); + assert.equal(viewer.preRender.size, 0, 'the cull listener is gone'); +}); + +test('a cast tile whose heights were evicted is cast again, not left draped', async () => { + const terrain = { + calls: 0, + async resolveEllipsoidalGround(coords) { + this.calls++; + return coords.map(() => ({ ellipsoid: 20, source: 'reearth' })); + }, + }; + // Room for one tile's corners at a time. + const groundCaster = createGroundCaster({ + terrain, + maxCorners: 16, + cacheMax: 20, + }); + let sampled = null; + const meshSampler = { + onSampled: (listener) => (sampled = listener), + request() {}, + meshAt: () => undefined, + }; + const { source, state, coverage, bytes, centre } = setup({ + surface: 'terrain', + groundCaster, + meshSampler, + }); + const isCast = () => { + const [entry] = state.coverage.tiles.values(); + return ( + entry.primitives.length > 0 && + entry.primitives.every(({ onGround }) => !onGround) + ); + }; + coverage.refresh(); + source.calls[0].resolve(bytes); + await settle(); + await settle(); + assert.ok(isCast(), 'cast once the heights are in'); + // Another region fills the cache and evicts this tile's corners... + await groundCaster.prepare([[centre.lon + 0.05, centre.lat + 0.05]]); + assert.equal(groundCaster.groundAt(centre.lon - 0.001, centre.lat), null); + // ...then mesh samples redraw the tile, which can only drape now. + sampled([[centre.lon, centre.lat]]); + await new Promise((resolve) => setTimeout(resolve, 5)); + coverage.refresh(); + await settle(); + await settle(); + assert.ok(isCast(), 'the tile is cast again'); + coverage.clear(); +}); + +test('a tile left draped by a failed terrain lookup is cast on the next refresh', async () => { + let up = false; + let prepares = 0; + let heightsReady = false; + const groundCaster = { + prepareLines: async () => { + prepares++; + heightsReady = up; + return up; + }, + castLine: (coords) => + heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null, + }; + const { viewer, source, coverage, bytes } = setup({ + surface: 'terrain', + groundCaster, + }); + coverage.refresh(); + source.calls[0].resolve(bytes); + await settle(); + await settle(); + assert.equal(prepares, 1); + assert.equal(viewer.scene.primitives.items.size, 0, 'draped'); + await settle(); + assert.equal(prepares, 1, 'no retry loop while the proxy is down'); + up = true; // the proxy is back + coverage.refresh(); + await settle(); + assert.equal(prepares, 2, 'the next refresh tries again'); + assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines added'); + coverage.clear(); +}); + +test('the per-tile cap applies after the imagery filter, so a dense tile keeps its 360° lines', async () => { + const { state, source, coverage, centre } = setup(); + const line = (n) => [ + [ + [centre.lon - 0.001, centre.lat + n * 1e-6], + [centre.lon + 0.001, centre.lat + n * 1e-6], + ], + ]; + // 700 newer flat sequences, then 300 older 360° ones. + const sequences = []; + for (let i = 0; i < 700; i++) + sequences.push({ + id: `flat-${i}`, + parts: line(i), + capturedAt: 2_000_000_000_000 + i, + }); + for (let i = 0; i < 300; i++) + sequences.push({ + id: `pano-${i}`, + parts: line(i), + capturedAt: 1_000_000_000_000 + i, + isPano: true, + }); + state.filter = { pano: 'pano', sinceMs: null }; + coverage.refresh(); + source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences })); + await settle(); + const [entry] = state.coverage.tiles.values(); + const cap = Math.floor(COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES); + assert.equal(entry.count, Math.min(300, cap), 'every 360° line is drawn'); + assert.equal(coverage.sequenceCount(), Math.min(300, cap)); + assert.ok(coverage.findSequence('pano-0'), 'drawn lines can be looked up'); + assert.equal(coverage.findSequence('flat-699'), null); + state.filter = { pano: 'all', sinceMs: null }; + coverage.rebuild(); + assert.equal(entry.count, cap, 'all imagery is capped as before'); + assert.ok(coverage.findSequence('flat-699'), 'the newest flat line is drawn'); + assert.equal(coverage.findSequence('pano-299'), null, 'over the cap'); + coverage.clear(); +}); + +test('a selection made or cleared while a tile builds is applied once it is ready', async () => { + const { viewer, source, state, coverage, bytes } = setup(); + coverage.refresh(); + source.calls[0].resolve(bytes); + await settle(); + const [entry] = state.coverage.tiles.values(); + const value = (selected) => + Array.from( + Cesium.ColorGeometryInstanceAttribute.toValue( + selected + ? Cesium.Color.fromCssColorString(COLORS.selected) + : Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92), + ), + ); + /** Stand in for a primitive the worker has not finished, with its colours. */ + function building(record) { + const control = { ready: false, attributes: { color: undefined } }; + Object.defineProperty(record.primitive, 'ready', { + get: () => control.ready, + }); + record.primitive.getGeometryInstanceAttributes = (id) => + id === 'mly:seq:seq-1' ? control.attributes : undefined; + return control; + } + + // Selected while its line is still building: nothing to recolour yet... + const first = building(entry.primitives[0]); + state.sequence.selectedId = 'seq-1'; + coverage.recolorSequence('seq-1', true); + assert.equal(first.attributes.color, undefined); + // ...so the highlight lands once it is ready. + first.ready = true; + frame(viewer.postRender); + assert.deepEqual(Array.from(first.attributes.color), value(true)); + + // Rebuilt with the highlight baked in, then cleared mid-build: not stuck. + coverage.rebuild(); + const second = building(entry.primitives[0]); + coverage.recolorSequence('seq-1', false); + state.sequence.selectedId = null; + second.ready = true; + frame(viewer.postRender); + assert.deepEqual(Array.from(second.attributes.color), value(false)); + coverage.clear(); + assert.equal(viewer.postRender.size, 0, 'the selection watch is gone'); +});