From 34b4fdce57ece334ce2fea89afb039154076267d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 22:31:13 +0400 Subject: [PATCH] Release hidden CCTV geometry and excuse level-of-detail path counts CCTV kept the coverage and projection geometry of every camera the opening view passed on its way to the restored one; after each visibility update it now releases the coverage of cameras out of view and the planes of inactive cameras, keeping decoded media for reselection. The cold-start comparison accepts a field entry in the ledger allowlist that excuses one field of one set with a reason, and uses it for the infrastructure footprint counts: that level of detail keeps features chosen on the way to a view so they do not flicker while panning, which a cold start cannot reproduce. Presence checks ignore field entries. The fixtures give recent imagery a matching scene and date and traffic its deterministic starts, and traffic counts as drawn without every loaded vehicle visible. --- scripts/qa-ledger-allow.json | 15 +++- scripts/qa/coldStart.mjs | 36 +++++++--- scripts/qa/coldStart.test.mjs | 42 +++++++++++ scripts/qa/coldStartFixtures.mjs | 28 +++++++- scripts/qa/coldStartFixtures.test.mjs | 94 +++++++++++++++++------- scripts/qa/ledger.mjs | 4 +- src/data/cctv.test.mjs | 9 ++- src/layers/cctv/geometry.js | 27 +++++++ src/layers/cctv/rendering.js | 1 + src/layers/cctv/rendering.test.mjs | 100 ++++++++++++++++++++++++++ 10 files changed, 312 insertions(+), 44 deletions(-) diff --git a/scripts/qa-ledger-allow.json b/scripts/qa-ledger-allow.json index fe51488c7..fc7ed8d27 100644 --- a/scripts/qa-ledger-allow.json +++ b/scripts/qa-ledger-allow.json @@ -1 +1,14 @@ -[] +[ + { + "id": "local-dams-footprints", + "family": "ground", + "field": "count", + "reason": "Infrastructure level of detail keeps features chosen on the way to a view (incumbents and eviction grace, so footprints do not flicker while panning); a cold start reaches the view without that path." + }, + { + "id": "local-datacenters-footprints", + "family": "ground", + "field": "count", + "reason": "Infrastructure level of detail keeps features chosen on the way to a view (incumbents and eviction grace, so footprints do not flicker while panning); a cold start reaches the view without that path." + } +] diff --git a/scripts/qa/coldStart.mjs b/scripts/qa/coldStart.mjs index 258160863..fdd6b39f7 100644 --- a/scripts/qa/coldStart.mjs +++ b/scripts/qa/coldStart.mjs @@ -108,7 +108,7 @@ export function coldStartReady(layers, cold = false) { stats.count > 0 && movers.some( (row) => - row.id === 'traffic-fill' && row.count === stats.count && row.shown, + row.id === 'traffic-fill' && row.count > 0 && row.shown, ) ); if (['flights', 'military', 'transit'].includes(id)) @@ -317,16 +317,30 @@ export function compareColdStart( const next = cold.ledger.find( (other) => other.family === row.family && other.id === row.id, ); - if (next) - add( - layerOf(row.family, row.id), - row.family, - row.id, - compareProbe( - { kind: row.kind, count: row.count }, - { kind: next.kind, count: next.count }, - ), - ); + if (next) { + // A field entry excuses one field of one set, with its reason. + const rows = compareProbe( + { kind: row.kind, count: row.count }, + { kind: next.kind, count: next.count }, + ).filter((difference) => { + const exception = allow.find( + (entry) => + entry.field === difference.field && + entry.id === row.id && + (!entry.family || entry.family === row.family), + ); + if (exception) + skipped.push({ + layer: layerOf(row.family, row.id), + family: row.family, + id: row.id, + ...difference, + reason: exception.reason, + }); + return !exception; + }); + add(layerOf(row.family, row.id), row.family, row.id, rows); + } } for (const layer of new Set([ ...Object.keys(switched.layers), diff --git a/scripts/qa/coldStart.test.mjs b/scripts/qa/coldStart.test.mjs index 2791e32f5..24f57cd97 100644 --- a/scripts/qa/coldStart.test.mjs +++ b/scripts/qa/coldStart.test.mjs @@ -406,5 +406,47 @@ test('readiness waits for fixture publications, LOD and the locator reveal', () assert.equal(ready('transit'), false); bus.set(0, { show: true }); assert.equal(ready('transit'), true); + entry('traffic'); + assert.equal(ready('traffic'), false); + const cars = engine.movers.createSet({ + id: 'traffic-fill', + kind: 'point', + capacity: 10, + point: { sizePx: 4 }, + }); + cars.setCount(9); + assert.equal(ready('traffic'), true, 'fading vehicles need not all draw'); + for (let i = 0; i < 9; i++) cars.set(i, { show: false }); + assert.equal(ready('traffic'), false, 'hidden traffic is not ready'); + for (let i = 0; i < 9; i++) cars.set(i, { show: true }); + dataManager.layers.get('traffic').module.getStats = () => ({ + count: 10, + loading: true, + }); + assert.equal(ready('traffic'), false, 'population admission must finish'); engine.destroy(); }); + +test('a field entry excuses only that field of that set and never a missing set', () => { + const a = snapshot(), + b = snapshot(); + b.ledger[0].count = 2; + const allow = [ + { id: 'radio-selected', family: 'movers', field: 'count', reason: 'path' }, + ]; + const excused = compareColdStart(a, b, { ...options, allow }); + assert.deepEqual( + excused.differences.filter((row) => row.family === 'movers'), + [], + ); + assert.ok(excused.skipped.some((row) => row.reason === 'path')); + // The radio marker is declared session state; compare it as plain content. + b.ledger = []; + assert.ok( + compareColdStart(a, b, { + ...options, + allow, + sessionState: false, + }).differences.some((row) => row.field === 'present'), + ); +}); diff --git a/scripts/qa/coldStartFixtures.mjs b/scripts/qa/coldStartFixtures.mjs index a1d4c3cdf..3a03ae0a7 100644 --- a/scripts/qa/coldStartFixtures.mjs +++ b/scripts/qa/coldStartFixtures.mjs @@ -25,9 +25,11 @@ export async function coldStartFixtures(origin, group) { const epoch = group === 'space' ? SATELLITE_FIXTURE_EPOCH - : group === 'urban' - ? TRANSIT_EPOCH - : Date.now(); + : group === 'imagery' + ? Date.parse('2026-10-29T12:00:00Z') + : group === 'urban' + ? TRANSIT_EPOCH + : Date.now(); const png = await sharp({ create: { width: 256, height: 256, channels: 4, background: '#40506a' }, }) @@ -298,6 +300,9 @@ function installFeeds({ group, epoch, groups, ghost, launches, firms }) { return Math.floor(now()); } }; + // Mid-road starts keep the traffic population visible through birth fades + // without random road-end recycling during the comparison. + if (group === 'traffic') Math.random = () => 0.5; localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); window.__coldStartUnexpectedApi = []; const original = window.fetch.bind(window); @@ -315,6 +320,23 @@ function installFeeds({ group, epoch, groups, ghost, launches, firms }) { window.fetch = (input, options) => { const url = new URL(input.url ?? input, location.href), path = url.pathname; + // The image fixture also needs the snapshot API's explicit pixel proof. + if ( + group === 'imagery' && + url.hostname === 'wvs.earthdata.nasa.gov' && + path.startsWith('/api/v1/snapshot') + ) + return original(input, options).then((response) => + response.ok + ? new Response(response.body, { + status: response.status, + headers: { + ...Object.fromEntries(response.headers), + 'Data-Present': 'true', + }, + }) + : response, + ); if (path === '/api/flights') return json({ time: epoch / 1000, diff --git a/scripts/qa/coldStartFixtures.test.mjs b/scripts/qa/coldStartFixtures.test.mjs index 8f8dfac06..be52bb9cd 100644 --- a/scripts/qa/coldStartFixtures.test.mjs +++ b/scripts/qa/coldStartFixtures.test.mjs @@ -8,6 +8,8 @@ import { parseSatcatCsv } from '../../src/layers/satellites/classes.js'; import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js'; import { createTrafficDisplay } from '../../src/layers/traffic/render/display.js'; import { createAnimation } from '../../src/layers/traffic/animation.js'; +import { createRecentImagerySource } from '../../src/layers/recentImagery/source.js'; +import { createThumbnailLoader } from '../../src/layers/recentImagery/thumbnails.js'; const origin = 'http://127.0.0.1:4309'; test('every group has valid strict fixture routes', async () => { @@ -221,48 +223,81 @@ test('traffic uses deterministic road and flow tiles', async () => { assert.ok((await (await page.fetch(url)).arrayBuffer()).byteLength > 0); }); -test('traffic dots finish their birth fade with the fixture clock', async (t) => { +test('imagery fixtures admit a recent S30 scene through the real thumbnail loader', async (t) => { + const page = await installedPage('imagery'); + const source = createRecentImagerySource({ fetchImpl: page.fetch }); + const box = { west: -97.77, east: -97.71, south: 30.25, north: 30.29 }; + const now = new Date(page.fixture.epoch); + const { candidates } = await source.searchHls({ box, now }); + const candidate = candidates.find((row) => row.product === 'S30'); + assert.ok(candidate); + assert.ok(now - Date.parse(candidate.day) < 3 * 86_400_000); + const loader = createThumbnailLoader({ + requestThumbnail: source.getThumbnail, + createObjectUrl: () => 'blob:fixture', + revokeObjectUrl() {}, + }); + t.after(() => loader.destroy()); + const finished = new Promise((resolve) => { + loader.subscribe((key) => { + const state = loader.get(key); + if (!state.loading && state.status !== 'unknown') resolve(state); + }); + }); + loader.request(candidate, box); + const state = await finished; + assert.equal(state.status, 'present'); + assert.equal(state.objectUrl, 'blob:fixture'); +}); + +test('traffic fixture dots finish their birth fade and stay visible during comparison', async (t) => { const page = await installedPage('traffic'); t.mock.method(Date, 'now', () => vm.runInContext('Date.now()', page.context)); - const engine = createFakeEngine(); + t.mock.method(Math, 'random', () => + vm.runInContext('Math.random()', page.context), + ); + const engine = createFakeEngine({ altitude: 1800 }); const display = createTrafficDisplay(engine); t.after(() => { display.destroy(); engine.destroy(); }); - const point = { - position: { x: 6378137, y: 0, z: 0 }, - show: true, - pixelSize: 6, - color: { red: 1, green: 1, blue: 1, alpha: 1 }, - outlineWidth: 0, - }; - const dot = { - point, - born: Date.now(), - nominalSize: 6, - direction: 1, - segIdx: 0, - numSegments: 1, - t: 0.5, - mps: 1, - stoppedUntil: 0, - segmentDist: [1000], - waypoints: [point.position, { x: 6378137, y: 1000, z: 0 }], - }; const animation = createAnimation({ state: { _enabled: true, _renderHeld: true, _engine: engine, _display: display, - _dots: [dot], + _dots: [], + _scratchLerp: {}, + _nextDotId: 1, + _speedScale: 1, + _fadeScaleFar: 100000, + _fadeTransFar: 100000, + _bucketCounts: { sim: 0 }, + _motion: { added: 0, recycled: 0 }, _lastAnimTime: Date.now(), _animFrame: 0, }, services: {}, - parts: { rendering: {}, retention: { easeHeights() {} } }, + parts: { + rendering: {}, + retention: { easeHeights() {} }, + style: { baseDotSize: () => 6, activeSizeDelta: () => 0 }, + }, }); + animation.spawnDotsForRoad( + { + type: 'primary', + segmentDist: [10000], + waypoints: [ + { x: 6378137, y: 0, z: 0 }, + { x: 6378137, y: 10000, z: 0 }, + ], + }, + 1800, + 10, + ); animation.animate(); animation.drawDots(); assert.equal( @@ -274,6 +309,15 @@ test('traffic dots finish their birth fade with the fixture clock', async (t) => animation.drawDots(); assert.equal( engine.movers.list().find(({ id }) => id === 'traffic-fill').count, - 1, + 10, ); + for (let i = 0; i < 300; i++) { + page.advance(100); + animation.animate(); + animation.drawDots(); + assert.equal( + engine.movers.list().find(({ id }) => id === 'traffic-fill').count, + 10, + ); + } }); diff --git a/scripts/qa/ledger.mjs b/scripts/qa/ledger.mjs index b71cf8739..c7a2860f6 100644 --- a/scripts/qa/ledger.mjs +++ b/scripts/qa/ledger.mjs @@ -42,7 +42,9 @@ export function compareLedgers(before, after, allow = []) { if (b.has(key)) return []; const exception = allow.find( (entry) => - entry.id === row.id && (!entry.family || entry.family === row.family), + !entry.field && + entry.id === row.id && + (!entry.family || entry.family === row.family), ); if (exception) { allowed.push({ diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index a9bb53a7c..80fe8183e 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -818,8 +818,11 @@ test('real active monitor plane owns one protected host label and no native labe showProjection: false, }); refreshCoverageStyles(); - assert.equal(runtime.planeEntity.show, false); - assert.equal(primitive(runtime.planeEntity.id).show, false); + assert.equal(runtime.planeEntity, null); + assert.equal( + engine.lines.list().some((line) => line.id.endsWith('-plane-outline')), + false, + ); assert.deepEqual(calls.slice(-2), [ ['clear', 'cctv-projection'], ['visible', 'cctv-projection', false], @@ -1210,7 +1213,7 @@ test('CCTV deselect publishes one null state and leaves the complete camera pose assert.equal(publications.at(-1).activeCameraId, null); assert.equal(publications.at(-1).activeCamera, null); assert.equal(publications.at(-1).enabled, true); - assert.equal(runtime.planeEntity.show, false); + assert.equal(runtime.planeEntity, null); assert.equal(record.probeClampRangeM, null); assert.equal(record.activationDone, false); assert.deepEqual(engine.camera.pose(), pose); diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js index 2a48af2f6..13189f388 100644 --- a/src/layers/cctv/geometry.js +++ b/src/layers/cctv/geometry.js @@ -744,6 +744,32 @@ export function createGeometry({ state: layerState, services, parts, source }) { ); } + function releaseInactiveGeometry(activeId, visibleIds) { + const removed = new Set(); + for (const record of layerState._records) { + if (record.camera.id === activeId) continue; + if (!visibleIds.has(record.camera.id)) { + for (const entity of record.coverageEntities || []) { + parts.renderer.removeCoverageEntity(entity); + removed.add(entity); + } + record.coverageEntities = []; + } + const runtime = record.projection; + if (runtime?.planeEntity) { + // Keep decoded media for reselection, but only the active plane draws. + parts.renderer.removePlane(runtime); + runtime.planeEntity = runtime.planeMaterial = null; + runtime.planeVisible = false; + runtime.needsPlane = true; + } + } + if (removed.size) + layerState._coverageEntities = layerState._coverageEntities.filter( + (entity) => !removed.has(entity), + ); + } + /** * Removes all coverage entities and projection runtimes from the scene. */ @@ -790,6 +816,7 @@ export function createGeometry({ state: layerState, services, parts, source }) { registerCoverageEntities, ensureActiveCoverageEntities, ensureVisibleCoverageEntities, + releaseInactiveGeometry, destroyCoverageEntities, }; } diff --git a/src/layers/cctv/rendering.js b/src/layers/cctv/rendering.js index cf05fe92b..1f6fd9b21 100644 --- a/src/layers/cctv/rendering.js +++ b/src/layers/cctv/rendering.js @@ -426,6 +426,7 @@ export function createRendering({ } } + parts.geometry.releaseInactiveGeometry(activeId, coverageVisible); parts.renderer?.setMarkersVisible(!!layerState._enabled); parts.projection.pauseInactiveProjectionFeeds(activeId); } diff --git a/src/layers/cctv/rendering.test.mjs b/src/layers/cctv/rendering.test.mjs index 3236f853f..2b6d41736 100644 --- a/src/layers/cctv/rendering.test.mjs +++ b/src/layers/cctv/rendering.test.mjs @@ -5,6 +5,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createRendering } from './rendering.js'; +import { createGeometry } from './geometry.js'; import { ACTIVE_CAMERA_COLOR, IDLE_CAMERA_COLOR, @@ -77,3 +78,102 @@ test('focus de-emphasis fades each icon from its own color', () => { ); assert.equal(state._activeFocusStyleCount, 3); }); + +test('coverage refresh releases departed geometry and preserves media for reselection', () => { + const records = ['opening', 'final', 'neighbor'].map((id) => ({ + camera: { id, headingConfidence: 'high' }, + frustumGeometry: {}, + frustumPositions: {}, + coverageEntities: [], + })); + const state = { + _enabled: true, + _coverageMode: 'on', + _showProjection: true, + _records: records, + _coverageEntities: [], + }; + let active = records[0]; + let visible = new Set(['opening']); + const lines = new Set(); + const planes = new Set(); + const services = { ground: {}, focus: {} }; + const parts = { + renderer: { + ready: true, + createCoverage(id) { + const line = { id, show: false }; + lines.add(line); + return [line]; + }, + removeCoverageEntity: (line) => lines.delete(line), + styleLine: (line, { visible }) => { + line.show = visible; + }, + removePlane: (runtime) => planes.delete(runtime.planeEntity), + setMarkersVisible() {}, + }, + selection: { getActiveRecord: () => active }, + model: { isBrowserOverlayCamera: () => false }, + projection: { + ensureProjectionRuntime(record) { + record.projection ??= { canvas: {} }; + const runtime = record.projection; + if (!runtime.planeEntity) { + runtime.planeEntity = {}; + runtime.needsPlane = false; + planes.add(runtime.planeEntity); + } + }, + setPlaneVisible: (runtime, value) => { + runtime.planeVisible = value; + }, + pauseInactiveProjectionFeeds() {}, + }, + }; + parts.geometry = createGeometry({ state, services, parts }); + parts.geometry.buildCoverageVisibleSet = () => visible; + const rendering = createRendering({ state, services, parts }); + rendering.refreshCoverageStyles(); + const openingLine = records[0].coverageEntities[0]; + const media = records[0].projection.canvas; + assert.equal( + openingLine.show, + true, + 'startup without a restore draws immediately', + ); + active = records[1]; + visible = new Set(['final', 'neighbor']); + rendering.refreshCoverageStyles(); + assert.equal(lines.has(openingLine), false); + assert.deepEqual(records[0].coverageEntities, []); + assert.equal(records[0].projection.planeEntity, null); + assert.equal(records[0].projection.needsPlane, true); + assert.equal(records[0].projection.canvas, media); + assert.deepEqual( + [...lines].map((line) => line.id), + ['final', 'neighbor'], + ); + assert.ok([...lines].every((line) => line.show)); + assert.deepEqual(state._coverageEntities, [...lines]); + assert.equal(planes.size, 1); + const finalLine = records[1].coverageEntities[0]; + rendering.refreshCoverageStyles(); + assert.equal( + records[1].coverageEntities[0], + finalLine, + 'stationary geometry is reused', + ); + active = records[0]; + visible = new Set(); + rendering.refreshCoverageStyles(); + assert.equal( + lines.size, + 1, + 'active projection keeps its coverage outside the visible cohort', + ); + assert.equal(records[0].coverageEntities[0].show, true); + assert.notEqual(records[0].coverageEntities[0], openingLine); + assert.equal(records[0].projection.canvas, media); + assert.equal(planes.size, 1); +});