diff --git a/changelog.d/1555-navara-globe-engine.md b/changelog.d/1555-navara-globe-engine.md index 246f4bb42..c9fa9b0e9 100644 --- a/changelog.d/1555-navara-globe-engine.md +++ b/changelog.d/1555-navara-globe-engine.md @@ -1,5 +1,5 @@ - Add Navara, an experimental three.js/WASM globe, as a second engine next to - Cesium, switchable live under Display → Globe Engine or with + Cesium, switchable live under VISUAL PRESETS → Globe Engine or with `?engine=navara`. Navara draws at CSS pixels like Cesium; Navara HD (`?engine=navara-hd`) draws at the screen's full resolution and needs about 1 GiB more on a Retina screen. Layers keep their data across a switch and diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index df35b8813..bf7a5eac6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -154,7 +154,7 @@ implementations. ## Navara globe engine — November 5, 2026 Navara, an experimental three.js/WASM globe (`@navaramap/three` 0.2.1), runs -next to Cesium. It is picked under Display → Globe Engine or with +next to Cesium. It is picked under VISUAL PRESETS → Globe Engine or with `?engine=navara`; Cesium stays the default. Switching is live: the page keeps one application, and only the globe-bound parts move to the next engine. @@ -183,10 +183,7 @@ one application, and only the globe-bound parts move to the next engine. `node scripts/qa-contract-suite.mjs ` runs them in headless Chrome on the real Cesium and Navara of a keyless dev server, with a real pointer and real canvases; checks of state the app drives itself (contact sonar) - are skipped and named. On November 7, 2026, with the imagery checks, - Cesium passed 68 of 68 and Navara 71 of 71 (the camera-events check now - and then misses its moveEnd on a busy machine on either engine and passes - when run again). `movers` and `lines` are required of every engine: Cesium + are skipped and named. `movers` and `lines` are required of every engine: Cesium draws them with billboards, points, models and polyline primitives (`src/globe/cesium/movers.js`, `lines.js`). - **Fills on the ground** (`engine.ground`) are required of every engine @@ -211,13 +208,8 @@ one application, and only the globe-bound parts move to the next engine. segments (`corridors.js`): 10,000 corridors draw in about 12 ms still and 14 ms moving, where Cesium takes 23 ms (the chunks before took 151 and 196 ms). - - Navara's vector layers were measured against that pass and lost - (`docs/GLOBE-PRIMITIVES.md`). - - On November 7, 2026, `scripts/qa-ground-suite.mjs` (the ground checks - with pixels, the corridor looks among them) passed 4 of 4 on both - engines, and `scripts/qa-ground-looks.mjs` drew the jam line and the - route on Cesium as the old materials did, with no pixel changed at four - moments of the pulse and the dashes. + - `scripts/qa-ground-suite.mjs` checks ground pixels and corridor looks + on both engines. - **Ground and input parity.** Space Missions draws its 500 m launch-pad zone with the radial ground material: faint fill, bright soft rim and emission, classified on terrain and tiles. Transit draws its age-banded @@ -257,12 +249,8 @@ one application, and only the globe-bound parts move to the next engine. - Sea Marks and Ski Pistes use `engine.imagery` on engines without `maps.addOverlay`; Navara retains its native raster path for tile detail. Imported KML/KMZ ground images use imagery on both engines. - - On November 7, 2026, `scripts/qa-ground-suite.mjs --only=imagery:` (the - imagery checks with pixels) passed 2 of 2 on both engines, and - `scripts/qa-imagery-compare.mjs` found the heatmap and Recent Imagery - drawn as before on Cesium (no pixel changed where they draw) and on - Navara (no channel off by more than 1 of 255, but for the heatmap from - orbit, which Navara now mipmaps as Cesium does). + - `scripts/qa-ground-suite.mjs --only=imagery:` checks imagery pixels + on both engines. Navara mipmaps the heatmap at orbit scale. - **Engine coupling.** `node scripts/report-engine-coupling.mjs` counts the engine-specific code outside the engines: renderer lines per engine, Cesium imports, `viewer` mentions, engine-name checks, raw Navara reads, Navara @@ -412,10 +400,8 @@ one application, and only the globe-bound parts move to the next engine. A per-view pool hands released ids back out (`src/globe/navara/movers/batchIds.js`); the long soak asserts both JS objects and native memory stay flat. -- **Navara 0.2.1 workaround.** The first `layer.update({ show: false })` after - a vector layer is added is sometimes dropped, so a zone turned off right - after a switch stayed drawn. Polygon displays hide by deleting their layers - and rebuild them when shown (`src/layers/shared/render/navaraPolygons.js`). +- **Ground visibility.** Polygon displays show and hide engine ground sets + through the screen pass on Navara. ## Wildlife Sightings — iNaturalist and Acartia @@ -2787,7 +2773,7 @@ same hues, from a bright red edge through orange and gold to a pale white-hot core. The edge starts more than half opaque, so regions far below the hottest ones still read over forest and savanna imagery. Nothing is painted where nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery -tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt +tile on the active imagery host (`src/layers/firms/render/display.js`), rebuilt on each data refresh or filter change (the first far-band paint at once, later ones 150 ms after the last change). Heatmap and markers are both kept built at every zoom and cross-fade by camera height between 3,600 and diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index 4845716f2..5120d8374 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -251,113 +251,10 @@ terrain loads later. Picks are CPU picks at the ground under the pointer (`src/globe/ground.js`). -Navara's vector layers (`src/globe/navara/ground/vector.js`) were measured -as the other candidate and kept only as a prototype. The measurements are -below. - -### Measurements - -Setup: - -- Dubai, 1280×800, keyless, daylight; -- headless Chrome on an Apple M2 Pro (ANGLE Metal); -- `scripts/qa-ground-bench.mjs`. - -Frame times are medians, from preUpdate to a 1-pixel readPixels after -postRender. "Still" means the camera is at rest; "moving" means it turns -every frame. The empty scene takes about 6–9 ms in both cases. Memory is -measured after a GC, against the scene before the commit. - -Small polygons (10 vertices, 100–400 m, eight colours, 1 px outline): - -| N | Cesium still / moving | Navara pass still / moving | Navara vector still / moving | -| ------ | --------------------- | -------------------------- | ---------------------------- | -| 10 | 4.9 / 5.0 ms | 5.7 / 9.5 ms | 5.7 / 24.5 ms | -| 1 000 | 5.8 / 5.9 ms | 6.2 / 10.9 ms | 5.9 / 29.0 ms | -| 10 000 | 12.0 / 11.2 ms | 10.9 / 15.9 ms | 5.7 / 29.0 ms | - -What each approach costs, apart from frame time: - -| N | Cesium | Navara pass | Navara vector | -| ------ | ---------------------------- | ------------------------- | -------------------------------- | -| 10 | 0.2 s build, +0.2 MB WebGL | 16 ms build, +0.04 MB GPU | 0.24 s build, about +70 MB WebGL | -| 1 000 | 0.23 s build, +19.5 MB WebGL | 68 ms build, +0.45 MB GPU | 0.23 s build, +144 MB WebGL | -| 10 000 | 0.8 s build, +40 MB WebGL | 0.26 s build, +4.0 MB GPU | 0.5 s build, +146 MB WebGL | - -The JS heap at 10 000 polygons: Cesium +47 MB, Navara pass +16 MB, Navara -vector +19 MB. - -One polygon of N vertices across the view (2 px outline), still: - -| N | Cesium | Navara pass | Navara vector | -| ----- | ------ | ----------- | ------------- | -| 100 | 6.2 ms | 6.6 ms | 5.3 ms | -| 1 000 | 5.9 ms | 7.0 ms | 5.3 ms | -| 5 000 | 6.4 ms | 7.3 ms | 6.3 ms | - -Without the bins, the pass took 46 ms at 1 000 vertices and 137 ms at 5 000. - -Corridors (40-point lines, 2.5 px), still / moving: - -| N | Cesium | Navara pass | Navara vector | -| ------ | -------------- | -------------- | ------------- | -| 10 | 5.8 / 5.8 ms | 5.7 / 9.7 ms | 6.6 / 25.8 ms | -| 1 000 | 11.2 / 11.2 ms | 18.6 / 24.4 ms | 6.7 / 28.4 ms | -| 10 000 | 21.6 / 21.6 ms | 135 / 174 ms | 6.3 / 28.0 ms | - -At 10 000 corridors Cesium allocated 613 MB of WebGL memory, the pass 44 MB -and the vector layers about 70 MB. - -The pass with corridor tiles, measured again with the pass before them on -the same day (the machine busier, the empty scene 6.7–8.1 ms): - -| N | Cesium | pass before | pass with tiles | -| ------ | -------------- | -------------- | --------------- | -| 1 000 | 12.8 / 13.9 ms | 19.8 / 24.9 ms | 8.7 / 10.0 ms | -| 10 000 | 23.1 / 23.2 ms | 151 / 196 ms | 11.8 / 13.8 ms | -| 15 000 | 31.8 / 31.4 ms | 226 / 289 ms | 17.9 / 19.9 ms | - -The tiles hold about what the chunks held: 43 MB of textures at 10 000 -corridors (46 MB before), 66 MB at 15 000 (69 MB). Packing takes longer: -the commit of 10 000 corridors went from 0.6 s to 1.1 s, and 15 000 from -0.8 s to 1.7 s. - -A pixel corridor's width is now measured across the line (the gradient of -the distance to it), so it is the same at any angle, as on Cesium. Before, -a diagonal line drew up to √2 wider. - -Edge quality over the Hajar mountains, oblique view: - -- **Cesium:** a sharp drape with its outline. -- **Navara pass:** the same. -- **Navara vector layers:** they are lit and tone-mapped like the scene, so - a fill's colour is not the one asked for. They draw under Navara's clouds. - A clamped polygon's outline is not drawn at all. - -Show and hide (`scripts/qa-ground-switch.mjs`), before and after a Navara → -Cesium → Navara switch: - -- **The pass:** correct every time. -- **Vector layers hidden by `layer.update({ show: false })`:** never hidden, - before or after a switch. -- **Vector layers hidden by deleting them** (today's workaround in - `navaraPolygons.js`): correct. - -### Decision - -**Navara draws every kind through the screen pass.** For polygons, discs and -cells it is as fast as Cesium up to 10 000 items. It holds a few megabytes -instead of a hundred or more. It draws exact colours and outlines, and its -show and hide are reliable. - -The vector layers draw thin lines cheaply at rest. They cost about 20 ms -more on every frame the camera moves, whatever their count. They draw no -ground outlines and no metre widths. Their colours are lit, and hiding them -needs a rebuild. - -Corridors were the pass's limit: 10 000 lines took 135 ms. With a -set-wide grid of bins (corridor tiles), 10 000 take 12 ms still and 14 ms -moving, under Cesium's 23 ms. +Navara draws ground shapes through the screen pass. It keeps colours and +outlines exact, supports pixel and metre widths, and reliably shows and +hides sets. Binned corridor tiles keep draw cost bounded while the camera +moves, with compact texture storage. ## Imagery: rasters on the ground diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 423d59b9d..f520e9c76 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -30,16 +30,12 @@ const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file); const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/; // Navara renders through three.js and its postprocessing peer. const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/; -// Navara renderers outside a layer's render/ directory: like those, they -// reach the app only through the Navara mode's import(). -const NAVARA_SCENE_RENDERERS = new Set(); // The modules that load Navara renderers, each through import(). const NAVARA_RENDERER_LOADERS = new Set([ 'src/app/navaraMode.js', 'src/app/layers/renderers.js', ]); -const navaraLayerRenderer = (file) => - /\/render\/navara[^/]*\.js$/.test(file) || NAVARA_SCENE_RENDERERS.has(file); +const navaraLayerRenderer = (file) => /\/render\/navara[^/]*\.js$/.test(file); const renderer = (file) => /^src\/(?:ui|app|standalone|overlays)\//.test(file) || /^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test( diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index 05e830c14..a0009d771 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -1,9 +1,9 @@ { "metrics": { "cesiumLayerRendererLines": 0, - "navaraLayerRendererLines": 189, + "navaraLayerRendererLines": 0, "cesiumImportFiles": 3, - "viewerLines": 891, + "viewerLines": 849, "navaraKindChecks": 1, "engineSniffCalls": 0, "engineExtensionReads": 3, @@ -12,5 +12,5 @@ "screenSpaceHandlerFiles": 4, "engineNameLines": 32 }, - "measuredAt": "Scope render demand to scene attachments and make the Cesium render backend independent" + "measuredAt": "Delete dead render code" } diff --git a/scripts/fixtures/w2/combined.mjs b/scripts/fixtures/w2/combined.mjs index f4548d1a4..00352804a 100644 --- a/scripts/fixtures/w2/combined.mjs +++ b/scripts/fixtures/w2/combined.mjs @@ -1,25 +1,7 @@ -import { cameras as alprCameras, cameraTile } from './alpr.mjs'; -import { cameras as cctvCameras, cameraFrame } from './cctv.mjs'; -import { stations as radioStations } from './radio.mjs'; -import { earthquakePerformanceFixture } from './earthquakes.mjs'; - -export const COMBINED_EPOCH = Date.parse('2026-10-28T18:00:00Z'); -export const COMBINED_ORIGIN = { lat: 42.3554, lon: -71.0605 }; +const ORIGIN = { lat: 42.3554, lon: -71.0605 }; const grid = (i, cols, spacing) => ({ - lat: COMBINED_ORIGIN.lat + (Math.floor(i / cols) - cols / 2) * spacing, - lon: COMBINED_ORIGIN.lon + ((i % cols) - cols / 2) * spacing, -}); -const radio = radioStations.map((r, i) => ({ ...r, ...grid(i, 28, 0.001) })); -const cctv = cctvCameras.map((r, i) => ({ - ...r, - ...grid(i, 5, 0.002), - cityId: 'boston', - city: 'Boston', - groundElevationM: 20, -})); -const alpr = alprCameras.map((r, i) => { - const p = grid(i, 19, 0.001); - return { ...r, latitude: p.lat, longitude: p.lon }; + lat: ORIGIN.lat + (Math.floor(i / cols) - cols / 2) * spacing, + lon: ORIGIN.lon + ((i % cols) - cols / 2) * spacing, }); const bikes = Array.from({ length: 200 }, (_, i) => ({ station_id: `w2-bike-${i}`, @@ -27,154 +9,24 @@ const bikes = Array.from({ length: 200 }, (_, i) => ({ capacity: 20, ...grid(i, 15, 0.0015), })); -const earthquakes = earthquakePerformanceFixture(500); -earthquakes.features = earthquakes.features.map((r, i) => { - const p = grid(i, 23, 0.002); - return { - ...r, - properties: { ...r.properties, time: COMBINED_EPOCH - 3600000 }, - geometry: - i < 5 ? { ...r.geometry, coordinates: [p.lon, p.lat, 5] } : r.geometry, - }; -}); -const fires = Array.from({ length: 6000 }, (_, i) => ({ - ...grid(i, 78, 0.001), - frp: 5 + (i % 120), - confidence: ['low', 'nominal', 'high'][i % 3], - brightness: 280 + (i % 90), - daynight: i % 2 ? 'N' : 'D', - acqDate: '2026-10-28', - acqTime: '1730', - instrument: 'VIIRS', - satellite: 'NOAA-20', -})); -const elements = Array.from({ length: 20 }, (_, i) => { - const p = grid(i, 5, 0.006), - d = 0.0007; - return { - type: 'way', - id: 980000 + i, - center: p, - tags: { military: 'airfield', name: `W2 facility ${i}` }, - bounds: { - minlat: p.lat - d, - minlon: p.lon - d, - maxlat: p.lat + d, - maxlon: p.lon + d, - }, - geometry: [ - [p.lon - d, p.lat - d], - [p.lon + d, p.lat - d], - [p.lon + d, p.lat + d], - [p.lon - d, p.lat + d], - [p.lon - d, p.lat - d], - ].map(([lon, lat]) => ({ lon, lat })), - }; -}); -export const combinedPayloads = { - radio, - cctv, - alpr, - bikes, - earthquakes, - fires, - elements, -}; +export const combinedPayloads = { bikes }; -/** One source transport fixture, shared unchanged by both engines. */ -export async function installCombinedFixture( - page, - { denseQuakes = false } = {}, -) { - const quakePayload = denseQuakes - ? { - ...earthquakes, - features: earthquakes.features.map((r, i) => { - const p = grid(i, 23, 0.002); - return { - ...r, - geometry: { ...r.geometry, coordinates: [p.lon, p.lat, 5] }, - }; - }), - } - : earthquakes; - const frame = await cameraFrame(), - requests = []; +/** Bikeshare and startup feed fixtures shared by both engines. */ +export async function installCombinedFixture(page) { + const requests = []; const session = await page.createCDPSession(); await session.send('Fetch.enable', { - patterns: [ - ...['127.0.0.1', 'localhost'].map((host) => ({ - urlPattern: `http://${host}:*/api/*`, - })), - { urlPattern: 'https://earthquake.usgs.gov/*all_day.geojson' }, - { urlPattern: 'https://tiles.dontgetflocked.com/*' }, - { urlPattern: '*://*.earthdata.nasa.gov/*' }, - ], + patterns: ['127.0.0.1', 'localhost'].map((host) => ({ + urlPattern: `http://${host}:*/api/*`, + })), }); let disposed = false; session.on('Fetch.requestPaused', async (event) => { try { const u = new URL(event.request.url); - let body, - type = 'application/json'; + let body; const p = u.pathname; - if (u.hostname === 'earthquake.usgs.gov') body = quakePayload; - else if (u.hostname === 'tiles.dontgetflocked.com') { - if (p.endsWith('.json')) - body = { - tiles: [ - 'https://tiles.dontgetflocked.com/cameras-us-hourly/{z}/{x}/{y}.mvt', - ], - bounds: [-160, 17, -64, 59], - }; - else { - const m = p.match(/\/(\d+)\/(\d+)\/(\d+)\.mvt$/); - body = m - ? cameraTile(...m.slice(1).map(Number), alpr) - : Buffer.alloc(0); - type = 'application/x-protobuf'; - } - } else if (u.hostname === 'cmr.earthdata.nasa.gov') - body = { - hits: 2, - items: ['2026-10-28', '2026-10-27'].map((day) => ({ - umm: { - TemporalExtent: { - RangeDateTime: { BeginningDateTime: `${day}T12:00:00Z` }, - }, - AdditionalAttributes: [{ Name: 'CLOUD_COVERAGE', Values: ['0'] }], - SpatialExtent: { - HorizontalSpatialDomain: { - Geometry: { - BoundingRectangles: [ - { - WestBoundingCoordinate: -72, - EastBoundingCoordinate: -70, - SouthBoundingCoordinate: 41, - NorthBoundingCoordinate: 43, - }, - ], - }, - }, - }, - }, - })), - }; - else if (u.hostname.endsWith('.earthdata.nasa.gov')) { - body = frame; - type = 'image/png'; - } else if (p === '/api/firms') - body = { fires, fetchedAt: COMBINED_EPOCH - 120000, stale: false }; - else if (p === '/api/radio/stations') - body = { - stations: radio, - updatedAt: new Date(COMBINED_EPOCH).toISOString(), - stale: false, - degraded: false, - acceptedGeneration: 1, - catalogInstance: 'w2-combined-fixture', - }; - else if (p.startsWith('/api/gbfs/')) + if (p.startsWith('/api/gbfs/')) body = { version: '3.0', data: { @@ -189,40 +41,11 @@ export async function installCombinedFixture( })), }, }; - else if (p === '/api/military-installations') - body = { - status: 'ready', - retrievedAt: new Date(COMBINED_EPOCH).toISOString(), - saturated: false, - elements, - }; - else if (p === '/api/cctv/sources') body = { sources: cctv }; - else if (p === '/api/cctv/health') - body = { - cameras: cctv.map(({ id }) => ({ - id, - status: 'online', - sourceKind: 'image', - updatedAt: COMBINED_EPOCH, - })), - }; - else if (p.startsWith('/api/cctv/frame/')) { - body = frame; - type = 'image/png'; - } else if (p.startsWith('/api/transit/')) - body = { - fetchedAt: COMBINED_EPOCH, - vehicles: [], - fixes: [], - epochs: [], - }; else if (p === '/api/flights') body = { states: [] }; else if (p === '/api/military') body = { ac: [] }; else if (p === '/api/vessels') body = { connected: false, vessels: [] }; else if (p === '/api/google/nearby-places') body = { places: [] }; else if (p === '/api/setup/status') body = { ready: true }; - else if (p === '/api/openai/hud-summary') - body = { summary: 'W2 performance fixture' }; else if (p === '/api/terrain/heights') body = { results: (u.searchParams.get('points') || '') @@ -244,15 +67,12 @@ export async function installCombinedFixture( requestId: event.requestId, responseCode: 200, responseHeaders: [ - { name: 'Content-Type', value: type }, + { name: 'Content-Type', value: 'application/json' }, { name: 'Access-Control-Allow-Origin', value: '*' }, { name: 'Access-Control-Expose-Headers', value: 'Data-Present' }, { name: 'Data-Present', value: 'true' }, ], - body: (Buffer.isBuffer(body) - ? body - : Buffer.from(JSON.stringify(body)) - ).toString('base64'), + body: Buffer.from(JSON.stringify(body)).toString('base64'), }); } catch (error) { if ( diff --git a/scripts/fixtures/w2/earthquakes.mjs b/scripts/fixtures/w2/earthquakes.mjs deleted file mode 100644 index 6060c7183..000000000 --- a/scripts/fixtures/w2/earthquakes.mjs +++ /dev/null @@ -1,83 +0,0 @@ -// USGS-shaped fixed observations; source parsers remain the production ones. -export const W2_EPOCH_MS = 1792152000000; -export function earthquakeFixture() { - const features = []; - for (const [region, lon, lat] of [ - ['sf', -122.42, 37.77], - ['austin', -97.74, 30.27], - ['alaska', -150.4, 61.0], - ]) { - for (let i = 0; i < 5; i++) { - features.push({ - type: 'Feature', - id: `qa-${region}-${i}`, - geometry: { - type: 'Point', - coordinates: [ - lon + (i - 2) * 0.24, - lat + (i % 2) * 0.3, - [8, 70, 300, 301, 45][i], - ], - }, - properties: { - mag: [2.5, 3, 4, 5, 5.5][i], - place: `W2 ${region} event ${i}`, - time: W2_EPOCH_MS - i * 60000, - }, - }); - } - } - features.push({ - type: 'Feature', - id: 'qa-below', - geometry: { type: 'Point', coordinates: [-122.42, 37.77, 5] }, - properties: { mag: 2.4 }, - }); - features.push({ - type: 'Feature', - id: 'qa-missing-mag', - geometry: { type: 'Point', coordinates: [-122.4, 37.7, 5] }, - properties: { mag: null }, - }); - return { - type: 'FeatureCollection', - metadata: { generated: W2_EPOCH_MS }, - features, - }; -} - -/** Dense regional and worldwide USGS-shaped load, separate from the oracle. */ -export function earthquakePerformanceFixture(count = 500) { - let seed = 17; - const random = () => { - seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; - return seed / 4294967296; - }; - return { - type: 'FeatureCollection', - features: Array.from({ length: count }, (_, i) => ({ - type: 'Feature', - id: `perf-quake-${i}`, - properties: { - mag: 2.5 + (i % 7) * 0.5, - place: `Recorded performance event ${i}`, - time: W2_EPOCH_MS - i * 1000, - }, - geometry: { - type: 'Point', - coordinates: - i < Math.min(100, count) - ? [ - -122.42 + (random() - 0.5), - 37.77 + (random() - 0.5), - [8, 70, 301][i % 3], - ] - : [ - -180 + random() * 360, - -70 + random() * 140, - [8, 70, 301][i % 3], - ], - }, - })), - }; -} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 01930d24b..d841d4d46 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1210,7 +1210,6 @@ "src/layers/military/renderer.test.mjs", "src/layers/military/policy.test.mjs", "src/testSupport/aircraftRecordingRenderer.mjs", - "src/testSupport/aircraftViewerRenderer.mjs", "src/testSupport/cesiumMoversEngine.mjs", "src/testSupport/cockpitEngine.mjs", "src/layers/aircraft/render/contract.js", @@ -1226,7 +1225,6 @@ "src/layers/vessels/testFixtures.mjs", "src/layers/shared/render/engineView.js", "src/globe/navara/ground/effect.js", - "src/layers/shared/render/navaraMoverBank.js", "src/layers/shared/render/moverBank.js", "src/layers/shared/render/imageAtlas.js", "src/layers/bikeshare/presentation.js", @@ -1258,8 +1256,6 @@ "src/globe/cesium/createCesiumEngine.test.mjs", "src/globe/cesium/movers.test.mjs", "src/globe/cesium/lines.test.mjs", - "src/globe/cesium/engineFromViewer.js", - "src/globe/cesium/engineFromViewer.test.mjs", "src/globe/cesium/testing/fakeViewer.mjs", "src/globe/cesium/testing/cameraViewer.mjs", "src/app/globeEngine.js", @@ -1376,7 +1372,6 @@ "src/layers/satellites/interaction.test.mjs", "src/layers/launches/lifecycleSnapshot.test.mjs", "src/globe/cesium/sceneProjection.js", - "src/globe/cesium/trail.js", "src/layers/flights/trackingRefresh.test.mjs", "scripts/qa-heading-navara.mjs", "src/layers/aircraft/qaDrawn.test.mjs", @@ -1389,7 +1384,6 @@ "src/layers/localAdsb/qaDrawn.js", "src/layers/localAdsb/qaDrawn.test.mjs", "src/tooling/w1RuntimeEvidence.test.mjs", - "scripts/fixtures/w2/earthquakes.mjs", "src/layers/firms/rectangle.js", "src/layers/firms/rectangle.test.mjs", "src/layers/firms/viewport.test.mjs", @@ -1561,7 +1555,6 @@ "src/layers/satellites/render/display.test.mjs", "src/layers/vessels/render/display.js", "src/layers/vessels/render/display.test.mjs", - "src/testSupport/vesselViewerRenderer.mjs", "scripts/qa-wildlife-switch.mjs", "scripts/fixtures/wildlife.mjs", "src/layers/cctv/render/display.js", @@ -1588,10 +1581,7 @@ "src/globe/navara/ground/pack.js", "src/globe/navara/ground/pack.test.mjs", "src/globe/navara/ground/pass.js", - "src/globe/navara/ground/vector.js", "scripts/qa-ground-suite.mjs", - "scripts/qa-ground-bench.mjs", - "scripts/qa-ground-switch.mjs", "docs/GLOBE-PRIMITIVES.md", "src/layers/airQuality/render/display.js", "src/layers/airQuality/render/display.test.mjs", @@ -1639,8 +1629,6 @@ "src/layers/traffic/render/display.test.mjs", "src/layers/directions/edgeCases.test.mjs", "src/layers/streetLevel/edgeCases.test.mjs", - "scripts/qa-imagery-compare.mjs", - "scripts/qa-weather-compare.mjs", "src/globe/cesium/imagery.js", "src/globe/cesium/imagery.test.mjs", "src/globe/cesium/imageryShell.test.mjs", @@ -1654,9 +1642,8 @@ "src/layers/weather/render/display.test.mjs", "src/layers/wind/render/display.test.mjs", "src/layers/bhoteKoshi/render/display.test.mjs", - "src/layers/shared/render/navaraGroundOrder.test.mjs", + "src/globe/navara/navaraGroundOrder.test.mjs", "src/globe/navara/ground/corridors.js", - "scripts/qa-ground-looks.mjs", "src/globe/cesium/windFlow.js", "src/testSupport/imageryDisplayEngines.mjs", "src/data/geoImportDisplay.js", diff --git a/scripts/main-chunk-baseline.json b/scripts/main-chunk-baseline.json index 6b8e30420..5504df9b3 100644 --- a/scripts/main-chunk-baseline.json +++ b/scripts/main-chunk-baseline.json @@ -1,6 +1,6 @@ { "gzipBytes": 2066560, "allowanceBytes": 20666, - "measuredAt": "HEAD 47d63128 with the layer displays loaded through import() (src/app/layers/displays.js), keyless build (GOOGLE_MAPS_API_KEY and CESIUM_ION_TOKEN empty), entry index-DEjtUM5w.js, 6 322 899 bytes, gzip level 9; HEAD 47d63128 itself measured 2 113 641 and the merge of main 92c8f8d9 2 090 274", + "measuredAt": "Keyless production build with dynamically loaded layer displays", "allowance": "1% of the baseline; growth past it fails npm run check:main-chunk until the baseline is raised on purpose" } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5211a41c6..b6dfe5a38 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1214,7 +1214,6 @@ "src/ui/stores/globeActions.js", "src/ui/stores/recording.js", "src/ui/i18n.js", - "src/globe/cesium/engineFromViewer.js", "src/cameraAngles.js", "src/globe/contract.js", "src/geo/", @@ -1377,7 +1376,6 @@ "src/ui/stores/sceneInteractions.js", "src/ui/stores/dataPacks.js", "src/ui/i18n.js", - "src/globe/cesium/engineFromViewer.js", "src/globe/contract.js", "src/geo/" ], @@ -1466,7 +1464,6 @@ "src/geo/", "src/time/clock.js", "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js", "src/layers/shared/color.js", "src/layers/aircraft/qaDrawn.js", "src/data/bindContextStore.js" @@ -1536,7 +1533,6 @@ "src/geo/", "src/time/clock.js", "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js", "src/layers/aircraft/qaDrawn.js", "src/data/bindContextStore.js" ], @@ -1574,8 +1570,7 @@ "src/globe/cesium/sceneProjection.js", "src/globe/cesium/screenEvents.js", "src/geo/", - "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js" + "src/globe/contract.js" ], "external": ["cesium"] }, @@ -1622,7 +1617,6 @@ "src/globe/cesium/sceneProjection.js", "src/geo/", "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js", "src/overlays/bindOverlayHost.js", "src/data/bindContextStore.js" ], @@ -1797,7 +1791,6 @@ "src/geo/", "src/time/clock.js", "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js", "src/overlays/bindOverlayHost.js" ], "external": ["cesium"] @@ -1848,8 +1841,7 @@ "src/layers/installations/classLabel.js", "src/layers/shared/color.js", "src/geo/", - "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js" + "src/globe/contract.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1903,8 +1895,7 @@ "src/ui/unitLabels.js", "src/globe/cesium/sceneProjection.js", "src/geo/", - "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js" + "src/globe/contract.js" ], "external": ["cesium"] }, @@ -1966,7 +1957,6 @@ "src/i18n/locales/zh-Hant.json", "src/geo/", "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js", "src/layers/traffic/render/display.js" ], "external": ["pbf", "@mapbox/vector-tile"] @@ -2007,7 +1997,6 @@ "src/layers/shared/color.js", "src/geo/", "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js", "src/overlays/bindOverlayHost.js" ], "external": [] @@ -2652,7 +2641,6 @@ "src/data/trafficFlowStyle.js", "src/data/trafficPresetStyle.js", "src/data/trafficQueue.js", - "src/data/trailRenderer.js", "src/data/vesselLabels.js", "src/data/vesselTypes.js", "src/firstRunExperience.js", @@ -3441,7 +3429,6 @@ "src/globe/cesium/surface.js", "src/globe/cesium/movers.js", "src/globe/cesium/lines.js", - "src/globe/cesium/engineFromViewer.js", "src/globe/cameraMath.js", "src/globe/navara/cameraModel.js", "src/globe/navara/createNavaraEngine.js", @@ -3468,7 +3455,6 @@ "src/annotations/drawSurface.js", "src/globe/cesium/sceneProjection.js", "src/globe/cesium/screenEvents.js", - "src/globe/cesium/trail.js", "src/layers/alpr/overlay.js", "src/layers/alpr/presentation.js", "src/layers/bikeshare/selectionModel.js", @@ -3520,17 +3506,13 @@ "src/layers/shared/render/engineView.js", "src/globe/cesium/celestial.js", "src/celestialRing/view.js", - "src/layers/shared/render/navaraPolygons.js", "src/globe/navara/ground/pulse.js", "src/globe/navara/sceneDepth.js", "src/layers/bhoteKoshi/render/story.js", "src/globe/navara/windFlowMesh.js", - "src/layers/shared/render/navaraGroundPicks.js", - "src/layers/shared/render/groundRing.js", "src/layers/bikeshare/stationPoints.js", "src/layers/shared/viewRectangle.js", "src/layers/firms/rectangle.js", - "src/layers/shared/render/navaraMoverBank.js", "src/layers/shared/render/moverBank.js", "src/layers/radio/screenClusters.js", "src/layers/shared/render/imageAtlas.js", @@ -3805,7 +3787,6 @@ "src/data/trafficFlowStyle.js", "src/data/trafficPresetStyle.js", "src/data/trafficQueue.js", - "src/data/trailRenderer.js", "src/data/vesselLabels.js", "src/data/vesselTypes.js", "src/keylessGeocoder.js", @@ -4259,7 +4240,6 @@ "src/ui/stores/bhoteKoshiPanel.js", "src/hudReadouts.js", "src/ui/i18n.js", - "src/globe/cesium/engineFromViewer.js", "src/globe/cesium/createCesiumEngine.js", "src/globe/cesium/maps.js", "src/globe/cesium/terrain.js", @@ -4284,7 +4264,6 @@ "src/globe/contract.js", "src/globe/cesium/sceneProjection.js", "src/globe/cesium/screenEvents.js", - "src/globe/cesium/trail.js", "src/layers/alpr/overlay.js", "src/layers/alpr/presentation.js", "src/layers/bikeshare/selectionModel.js", @@ -4492,7 +4471,6 @@ "src/sources/capability.js", "src/data/cameraArrival.js", "src/data/cameraFraming.js", - "src/globe/cesium/engineFromViewer.js", "src/globe/cesium/credits.js", "src/globe/contract.js", "src/geo/", @@ -4524,7 +4502,6 @@ "layer-lifecycle": { "exports": ["./data/lifecycle"], "modules": [ - "src/globe/cesium/engineFromViewer.js", "src/globe/contract.js", "src/data/lifecycle.js", "src/data/layerSceneBinding.js", @@ -4552,7 +4529,6 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/i18n/index.js", - "src/globe/cesium/engineFromViewer.js", "src/globe/contract.js" ], "external": ["cesium"] @@ -5154,7 +5130,6 @@ "src/globe/cesium/terrain.js", "src/globe/cesium/maps.js", "src/globe/cesium/createCesiumEngine.js", - "src/globe/cesium/engineFromViewer.js", "src/globe/cesium/projector.js", "src/globe/cesium/render.js", "src/globe/cesium/credits.js", @@ -5752,7 +5727,6 @@ "src/globe/cesium/sceneProjection.js", "src/geo/", "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js", "src/globe/cesium/credits.js", "src/layers/wildlifeSightings/display.js", "src/data/spriteOrder.js", diff --git a/scripts/qa-all-layer-switches.mjs b/scripts/qa-all-layer-switches.mjs index df66382c7..6b0784502 100644 --- a/scripts/qa-all-layer-switches.mjs +++ b/scripts/qa-all-layer-switches.mjs @@ -18,6 +18,7 @@ import assert from 'node:assert/strict'; import puppeteer from 'puppeteer'; import { APPLICATION_LAYER_DIRECTORY } from '../src/app/layers/directory.js'; import { + assertPreviewPortAvailable, createStrict, ENGINES, localPreview, @@ -147,6 +148,7 @@ if (flag('serve')) { ? resolve(String(flag('scratch'))) : tmpdir(); mkdirSync(scratchRoot, { recursive: true }); + await assertPreviewPortAvailable(base.href); const scratch = mkdtempSync(join(scratchRoot, 'gev-acceptance-build-')); const worktree = join(scratch, 'checkout'); const dist = join(scratch, 'dist'); diff --git a/scripts/qa-app-once.mjs b/scripts/qa-app-once.mjs index 1d94a53b1..8e39f7fe3 100644 --- a/scripts/qa-app-once.mjs +++ b/scripts/qa-app-once.mjs @@ -1,3 +1,4 @@ +import { localPreview, waitForEngine } from './qa-strict-harness.mjs'; // The application is created once per page: switching engines detaches and // attaches only the engine-bound parts. This gate runs three Cesium→Navara→ // Cesium round trips and checks that every session object keeps its identity, @@ -14,15 +15,13 @@ import { listenerCounts, listenerDifference, openSessionPage, - previewBase, readSession, requestEngine, sessionAddress, switchEngine, - waitReady, } from './qa/appSessionHarness.mjs'; -const base = previewBase(process.argv[2]); +const base = localPreview(process.argv[2]); const reportOnly = process.argv.includes('--report'); const report = { switches: [], failures: [] }; const check = (condition, message, detail) => { @@ -139,7 +138,7 @@ try { browser, sessionAddress(base, { engine: 'cesium' }), ); - await waitReady(page, 'cesium'); + await waitForEngine(page, 'cesium', { timeout: 90_000 }); // Let the first boot's deferred listeners land before counting. await new Promise((resolve) => setTimeout(resolve, 2000)); const boot = await readSession(page); @@ -335,7 +334,7 @@ try { await page.waitForFunction(() => window.__qaRollback === true, { timeout: 60_000, }); - await waitReady(page, 'cesium'); + await waitForEngine(page, 'cesium', { timeout: 90_000 }); const rolledBack = await readSession(page); const ui = await readUi(page); report.rollback = { @@ -388,7 +387,7 @@ try { await page.waitForFunction(() => window.__qaDataRefused === true, { timeout: 60_000, }); - await waitReady(page, 'cesium'); + await waitForEngine(page, 'cesium', { timeout: 90_000 }); const afterDataFailure = await readSession(page); report.dataFailure = { ids: idDifference(boot.ids, afterDataFailure.ids), diff --git a/scripts/qa-cctv-engine-switch.mjs b/scripts/qa-cctv-engine-switch.mjs index cd875013e..bacdb3194 100644 --- a/scripts/qa-cctv-engine-switch.mjs +++ b/scripts/qa-cctv-engine-switch.mjs @@ -1,3 +1,4 @@ +import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import { promisify } from 'node:util'; import { execFile } from 'node:child_process'; @@ -55,10 +56,10 @@ await strict.run({}, async (b) => { window.__godsEyeView?.engine?.kind === 'navara' && document.querySelector('#loading-screen.hidden'), ); + await assertKeylessPreview(p); await p.evaluate(async () => { - const { dataManager, styleManager, tileset } = window.__godsEyeView; - if (tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Expected keyless preview'); + const { dataManager, styleManager } = window.__godsEyeView; + await styleManager.initialRestorePromise; await dataManager.setEnabled('cctv', true, { origin: 'user' }); window.qaCctv = dataManager.layers.get('cctv').module; diff --git a/scripts/qa-contact-switch.mjs b/scripts/qa-contact-switch.mjs index 166cae7b6..956713c52 100644 --- a/scripts/qa-contact-switch.mjs +++ b/scripts/qa-contact-switch.mjs @@ -90,9 +90,8 @@ await strict.run({}, async (b) => { await p.goto(base.href); await waitForEngine(p, 'navara'); await p.evaluate(async () => { - const { dataManager, styleManager, tileset } = window.__godsEyeView; - if (tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Require keyless preview'); + const { dataManager, styleManager } = window.__godsEyeView; + await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' }); window.qaBefore = [...dataManager.getEnabledLayerIds()].sort(); const outcome = await styleManager.setContextMode('contacts'); diff --git a/scripts/qa-directions-engine-switch.mjs b/scripts/qa-directions-engine-switch.mjs index 17b64dba8..66e9151d1 100644 --- a/scripts/qa-directions-engine-switch.mjs +++ b/scripts/qa-directions-engine-switch.mjs @@ -56,9 +56,8 @@ await strict.run({}, async (b) => { await p.goto(base.href); await waitForEngine(p, 'navara'); await p.evaluate(async () => { - const { tileset, dataManager } = window.__godsEyeView; - if (tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Keyless preview required'); + const { dataManager } = window.__godsEyeView; + window.qaRoute = dataManager.layers.get('directions').module; await dataManager.setEnabled('directions', true, { origin: 'user' }); }); diff --git a/scripts/qa-director-engine-switch.mjs b/scripts/qa-director-engine-switch.mjs index 17e9c2cfd..e26c467ef 100644 --- a/scripts/qa-director-engine-switch.mjs +++ b/scripts/qa-director-engine-switch.mjs @@ -1,3 +1,4 @@ +import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import sharp from 'sharp'; import { createStrict, localPreview } from './qa-strict-harness.mjs'; @@ -181,10 +182,10 @@ await strict.run({}, async (b) => { window.__godsEyeView?.sceneDirector && document.querySelector('#loading-screen.hidden'), ); + await assertKeylessPreview(p); await p.evaluate(async (project) => { const a = window.__godsEyeView; - if (window.__GOOGLE_MAPS_API_KEY__ || a.tileset) - throw Error('Expected keyless preview'); + await a.styleManager.initialRestorePromise; window.director = a.sceneDirector; await window.director.importProjectFile( diff --git a/scripts/qa-engine-switch.mjs b/scripts/qa-engine-switch.mjs index b04aed3ae..2e60003fe 100644 --- a/scripts/qa-engine-switch.mjs +++ b/scripts/qa-engine-switch.mjs @@ -1,3 +1,4 @@ +import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import sharp from 'sharp'; import { createStrict, localPreview } from './qa-strict-harness.mjs'; @@ -69,10 +70,9 @@ await strict.run({}, async (browser) => { { timeout: 90000 }, kind, ); + await assertKeylessPreview(page); await page.evaluate(async () => { await window.__godsEyeView.styleManager.initialRestorePromise; - if (window.__GOOGLE_MAPS_API_KEY__ || window.__godsEyeView.tileset) - throw new Error('Keyless preview required'); }); assert.equal(await page.$eval('#globe-engine', (el) => el.value), kind); } diff --git a/scripts/qa-fire-camera-switch.mjs b/scripts/qa-fire-camera-switch.mjs index c3f6d98c5..bc1426f53 100644 --- a/scripts/qa-fire-camera-switch.mjs +++ b/scripts/qa-fire-camera-switch.mjs @@ -1,3 +1,4 @@ +import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import { installAlprFixture, cameras } from './fixtures/w2/alpr.mjs'; import { firmsFixture, FIRMS_VALID_COUNT } from './fixtures/w2/firms.mjs'; @@ -46,10 +47,10 @@ await strict.run({}, async (b) => { window.__godsEyeView?.engine?.kind === 'navara' && document.querySelector('#loading-screen.hidden'), ); + await assertKeylessPreview(p); await p.evaluate(async (id) => { - const { dataManager, styleManager, tileset } = window.__godsEyeView; - if (window.__GOOGLE_MAPS_API_KEY__ || tileset) - throw Error('Keyless preview required'); + const { dataManager, styleManager } = window.__godsEyeView; + await styleManager.initialRestorePromise; window.qaGround = dataManager.layers.get(id).module; await dataManager.setEnabled(id, true, { origin: 'user' }); diff --git a/scripts/qa-ground-bench.mjs b/scripts/qa-ground-bench.mjs deleted file mode 100644 index b03c85efe..000000000 --- a/scripts/qa-ground-bench.mjs +++ /dev/null @@ -1,558 +0,0 @@ -#!/usr/bin/env node -/** - * Measure engine.ground at Dubai (1280×800, keyless) with N polygons: - * Cesium (GroundPrimitive), Navara's screen pass (engine.ground, - * src/globe/navara/ground/pass.js) and the Navara vector-layer prototype - * (src/globe/navara/ground/vector.js). For each N: the time from commit to - * the first item drawn, the frame time with the camera still and turning - * (preUpdate to postRender, closed by a 1-pixel readPixels so the GPU work - * of the frame is in it), the JS heap after a GC and the WebGL bytes the - * page allocated (a tally of bufferData, texImage/texStorage and - * renderbuffer storage, less deletions), each against the empty scene; then - * one polygon over the Hajar mountains, obliquely, for the edge quality. - * - * GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271 - * node scripts/qa-ground-bench.mjs http://localhost:4271 [--counts=10,1000,10000] - * [--paths=cesium,navara-pass,navara-vector] [--out=DIR] - */ -import fs from 'node:fs'; -import path from 'node:path'; -import puppeteer from 'puppeteer'; - -const BLOCKED_HOSTS = - /(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/; -const args = process.argv.slice(2); -const origin = args.find((arg) => !arg.startsWith('--')); -const option = (name) => - args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); -if (!origin) { - console.error('Usage: node scripts/qa-ground-bench.mjs [options]'); - process.exit(2); -} -const counts = (option('counts') ?? '10,1000,10000').split(',').map(Number); -// polygons: N small polygons; corridors: N 40-point lines across the view, -// 2.5 px; outline: one polygon of N vertices round the view. -const shape = option('shape') ?? 'polygons'; -const paths = (option('paths') ?? 'cesium,navara-pass,navara-vector').split( - ',', -); -const out = option('out') ?? null; -if (out) fs.mkdirSync(out, { recursive: true }); - -// The page's WebGL allocations, before any script of the app runs. -function installGpuTally() { - const proto = WebGL2RenderingContext.prototype; - const sizes = new Map(); - let total = 0; - const boundBuffers = new WeakMap(); - const boundTextures = new WeakMap(); - const boundRenderbuffers = new WeakMap(); - const set = (key, bytes) => { - total += bytes - (sizes.get(key) ?? 0); - sizes.set(key, bytes); - }; - const drop = (key) => { - total -= sizes.get(key) ?? 0; - sizes.delete(key); - }; - const per = (map, gl) => { - if (!map.has(gl)) map.set(gl, new Map()); - return map.get(gl); - }; - const FLOAT = 0x1406; - const HALF_FLOAT = 0x140b; - const internalBytes = { - 0x8814: 16, // RGBA32F - 0x881a: 8, // RGBA16F - 0x8815: 12, // RGB32F - 0x822e: 4, // R32F - 0x8230: 8, // RG32F - 0x8058: 4, // RGBA8 - 0x8c43: 4, // SRGB8_ALPHA8 - 0x88f0: 4, // DEPTH24_STENCIL8 - 0x8cad: 8, // DEPTH32F_STENCIL8 - 0x8cac: 4, // DEPTH_COMPONENT32F - 0x81a6: 4, // DEPTH_COMPONENT24 - 0x81a5: 2, // DEPTH_COMPONENT16 - 0x8229: 1, // R8 - 0x822b: 2, // RG8 - }; - const texelBytes = (internal, type) => - internalBytes[internal] ?? - (type === FLOAT ? 16 : type === HALF_FLOAT ? 8 : 4); - const wrap = (name, after) => { - const original = proto[name]; - proto[name] = function (...call) { - const result = original.apply(this, call); - try { - after.call(this, call, result); - } catch { - // A tally error never breaks the page. - } - return result; - }; - }; - wrap('bindBuffer', function ([target, buffer]) { - per(boundBuffers, this).set(target, buffer); - }); - wrap('bufferData', function ([target, data]) { - const buffer = per(boundBuffers, this).get(target); - if (buffer) - set(buffer, typeof data === 'number' ? data : (data?.byteLength ?? 0)); - }); - wrap('deleteBuffer', ([buffer]) => drop(buffer)); - wrap('bindTexture', function ([target, texture]) { - per(boundTextures, this).set(target, texture); - }); - wrap('texImage2D', function (call) { - const texture = per(boundTextures, this).get(call[0]); - if (!texture || call[1] !== 0) return; - let bytes; - if (call.length >= 8 && typeof call[3] === 'number') - bytes = call[3] * call[4] * texelBytes(call[2], call[7]); - else { - const source = call[5]; - bytes = (source?.width ?? 0) * (source?.height ?? 0) * 4; - } - set(texture, bytes); - }); - wrap('texStorage2D', function ([target, levels, internal, width, height]) { - const texture = per(boundTextures, this).get(target); - if (texture) - set( - texture, - width * height * texelBytes(internal) * (levels > 1 ? 4 / 3 : 1), - ); - }); - wrap('texImage3D', function (call) { - const texture = per(boundTextures, this).get(call[0]); - if (texture && call[1] === 0) - set(texture, call[3] * call[4] * call[5] * texelBytes(call[2], call[8])); - }); - wrap('texStorage3D', function ([target, , internal, width, height, depth]) { - const texture = per(boundTextures, this).get(target); - if (texture) set(texture, width * height * depth * texelBytes(internal)); - }); - wrap('deleteTexture', ([texture]) => drop(texture)); - wrap('bindRenderbuffer', function ([, renderbuffer]) { - boundRenderbuffers.set(this, renderbuffer); - }); - wrap('renderbufferStorage', function ([, internal, width, height]) { - const renderbuffer = boundRenderbuffers.get(this); - if (renderbuffer) set(renderbuffer, width * height * texelBytes(internal)); - }); - wrap( - 'renderbufferStorageMultisample', - function ([, samples, internal, width, height]) { - const renderbuffer = boundRenderbuffers.get(this); - if (renderbuffer) - set( - renderbuffer, - width * height * texelBytes(internal) * Math.max(1, samples), - ); - }, - ); - wrap('deleteRenderbuffer', ([renderbuffer]) => drop(renderbuffer)); - window.__gpuBytes = () => total; -} - -const DUBAI = { lon: 55.27, lat: 25.15, alt: 70000 }; - -async function openEngine(browser, kind) { - const page = await browser.newPage(); - await page.setViewport({ width: 1280, height: 800 }); - page.on('pageerror', (error) => console.log(' page error:', error.message)); - await page.evaluateOnNewDocument(installGpuTally); - await page.setRequestInterception(true); - page.on('request', (request) => { - if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort(); - else request.continue(); - }); - await page.goto( - `${origin}/?welcome=0&engine=${kind}#v=2&lat=${DUBAI.lat}&lon=${DUBAI.lon}&alt=${DUBAI.alt}&heading=0&pitch=-90&map=osm&l=`, - ); - await page.waitForFunction( - (expected) => - window.__godsEyeView?.engine?.kind === expected && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 300_000, polling: 250 }, - kind, - ); - // Daylight in Dubai (Navara lights the scene with the app clock's sun). - await page.evaluate(async () => { - const { appClock } = await import('/src/time/clock.js'); - appClock.freeze(Date.UTC(2026, 10, 7, 8, 0)); - window.__godsEyeView.engine.render.request('ground-bench-daylight'); - }); - const cdp = await page.createCDPSession(); - await cdp.send('HeapProfiler.enable'); - // Helpers in the page: polygons, the approach under test, timing. - await page.evaluate(() => { - const { engine } = window.__godsEyeView; - const random = (seed) => () => { - seed = (seed * 1664525 + 1013904223) >>> 0; - return seed / 2 ** 32; - }; - const PALETTE = [ - [1, 0.2, 0.2], - [1, 0.6, 0], - [1, 1, 0.2], - [0.2, 0.9, 0.3], - [0, 0.8, 1], - [0.3, 0.4, 1], - [0.7, 0.3, 1], - [0.9, 0.9, 0.9], - ]; - window.__bench = { - engine, - // N polygons over Dubai (deterministic): 10 vertices, 100–400 m; - // item 0 a 3 km magenta polygon at the view centre. - polygons(count) { - const next = random(7); - const items = []; - for (let index = 0; index < count; index++) { - const centre = - index === 0 - ? [55.27, 25.15] - : [54.95 + next() * 0.65, 24.95 + next() * 0.4]; - const radius = index === 0 ? 3000 : 100 + next() * 300; - const ring = []; - for (let k = 0; k < 10; k++) { - const angle = (k / 10) * 2 * Math.PI; - const r = radius * (0.75 + 0.25 * (index === 0 ? 1 : next())); - ring.push([ - centre[0] + - (r * Math.cos(angle)) / - (111320 * Math.cos((centre[1] * Math.PI) / 180)), - centre[1] + (r * Math.sin(angle)) / 110540, - ]); - } - items.push({ - rings: [ring], - // Eight colours, as the layers use a handful (depth bands, - // levels); the vector prototype makes one layer per style. - fill: - index === 0 - ? [1, 0, 1, 0.9] - : [...PALETTE[Math.floor(next() * PALETTE.length)], 0.35], - outline: { color: [1, 1, 1, 0.9], widthPx: 1 }, - pickId: index, - }); - } - return items; - }, - // N - 1 lines, then the magenta mark on top of them. - corridors(count) { - const next = random(11); - const items = []; - for (let index = 1; index < count; index++) { - let lon = 54.95 + next() * 0.65; - let lat = 24.95 + next() * 0.4; - const heading = next() * 2 * Math.PI; - const positions = []; - for (let k = 0; k < 40; k++) { - positions.push([lon, lat]); - const turn = heading + (next() - 0.5) * 1.2; - lon += 0.004 * Math.cos(turn); - lat += 0.004 * Math.sin(turn); - } - items.push({ - positions, - color: [...PALETTE[Math.floor(next() * PALETTE.length)], 0.9], - widthPx: 2.5, - pickId: index, - }); - } - items.push(window.__bench.centreDisc()); - return items; - }, - // The magenta mark of the corridors scenario: a 40 px corridor. - centreDisc: () => ({ - positions: [ - [55.255, 25.15], - [55.285, 25.15], - ], - color: [1, 0, 1, 0.9], - widthPx: 40, - pickId: 0, - }), - outline(count) { - const ring = []; - for (let k = 0; k < count; k++) { - const angle = (k / count) * 2 * Math.PI; - const wobble = 1 + 0.03 * Math.sin(angle * 37); - ring.push([ - 55.27 + 0.28 * wobble * Math.cos(angle), - 25.15 + 0.2 * wobble * Math.sin(angle), - ]); - } - return [ - { - rings: [ring], - fill: [1, 0, 1, 0.5], - outline: { color: [1, 1, 1, 1], widthPx: 2 }, - pickId: 0, - }, - ]; - }, - async ground(kind) { - if (kind !== 'navara-vector') return engine.ground; - const { createNavaraVectorGround } = - await import('/src/globe/navara/ground/vector.js'); - window.__bench.vector ??= createNavaraVectorGround( - engine.extensions.navara.view, - ); - return window.__bench.vector.ground; - }, - frame: () => - new Promise((resolve) => { - const remove = engine.frames.postRender.addEventListener(() => { - remove(); - resolve(); - }); - engine.render.request('ground-bench'); - }), - // Frame times in ms (preUpdate → postRender + 1 px readPixels). - async frameTimes(frames, turn = false) { - const gl = engine.host.canvas.getContext('webgl2'); - const pixel = new Uint8Array(4); - const times = []; - let start = 0; - const removeStart = engine.frames.preUpdate.addEventListener(() => { - start = performance.now(); - }); - const removeEnd = engine.frames.postRender.addEventListener(() => { - gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); - times.push(performance.now() - start); - }); - const pose = engine.camera.pose(); - try { - for (let index = 0; index < frames; index++) { - if (turn) - engine.camera.setView({ - destination: pose.position, - orientation: { - heading: pose.heading + (index % 2 ? 0.002 : -0.002), - pitch: pose.pitch, - roll: 0, - }, - }); - await window.__bench.frame(); - } - } finally { - removeStart(); - removeEnd(); - engine.camera.setView({ - destination: pose.position, - orientation: { heading: pose.heading, pitch: pose.pitch, roll: 0 }, - }); - } - times.sort((a, b) => a - b); - const mean = times.reduce((a, b) => a + b, 0) / times.length; - return { - mean, - median: times[Math.floor(times.length / 2)], - p95: times[Math.floor(times.length * 0.95)], - }; - }, - // The centre pixel shows the magenta item. - centreMagenta() { - const canvas = engine.host.canvas; - const c = document.createElement('canvas'); - c.width = 4; - c.height = 4; - const context = c.getContext('2d'); - context.drawImage( - canvas, - canvas.width / 2 - 2, - canvas.height / 2 - 2, - 4, - 4, - 0, - 0, - 4, - 4, - ); - const [r, g, b] = context.getImageData(1, 1, 1, 1).data; - // Lit (the vector layers) or exact (the pass, Cesium). - return r > 150 && b > 150 && g < Math.min(r, b) - 60; - }, - }; - }); - return { page, cdp }; -} - -async function settleTiles(page) { - await page.evaluate(async () => { - const { engine, frame } = window.__bench; - const started = performance.now(); - let loadedFrames = 0; - while (performance.now() - started < 60000 && loadedFrames < 30) { - await frame(); - loadedFrames = engine.surface.tilesLoaded() ? loadedFrames + 1 : 0; - } - }); -} - -async function memory(page, cdp) { - await cdp.send('HeapProfiler.collectGarbage'); - await cdp.send('HeapProfiler.collectGarbage'); - return page.evaluate(() => ({ - js: performance.memory.usedJSHeapSize, - gpu: window.__gpuBytes(), - navara: - window.__bench.engine.extensions?.navara?.view?.memoryStats?.() - ?.gpuBytesEst ?? null, - })); -} - -const mb = (bytes) => (bytes / 2 ** 20).toFixed(2); -const rows = []; - -async function measure(browser, kind) { - const engineKind = kind === 'cesium' ? 'cesium' : 'navara'; - const { page, cdp } = await openEngine(browser, engineKind); - const renderer = await page.evaluate(() => { - const gl = window.__bench.engine.host.canvas.getContext('webgl2'); - const info = gl.getExtension('WEBGL_debug_renderer_info'); - return info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : 'unknown'; - }); - console.log(`${kind}: ${renderer}`); - await settleTiles(page); - // Warm the approach (shader compile) with one item, then measure empty. - await page.evaluate(async (kind) => { - const ground = await window.__bench.ground(kind); - const set = ground.createSet({ id: 'bench-warm', kind: 'polygon' }); - set.replace(window.__bench.polygons(1)); - set.commit(); - const started = performance.now(); - while ( - !window.__bench.centreMagenta() && - performance.now() - started < 30000 - ) - await window.__bench.frame(); - set.destroy(); - for (let index = 0; index < 10; index++) await window.__bench.frame(); - }, kind); - const emptyFrames = { - still: await page.evaluate(() => window.__bench.frameTimes(90)), - turning: await page.evaluate(() => window.__bench.frameTimes(90, true)), - }; - rows.push({ path: kind, shape, count: 0, ...emptyFrames }); - for (const count of counts) { - const before = await memory(page, cdp); - const built = await page.evaluate( - async (kind, count, shape) => { - const ground = await window.__bench.ground(kind); - const items = - shape === 'corridors' - ? window.__bench.corridors(count) - : shape === 'outline' - ? window.__bench.outline(count) - : window.__bench.polygons(count); - const set = ground.createSet({ - id: 'bench', - kind: shape === 'corridors' ? 'corridor' : 'polygon', - }); - const started = performance.now(); - set.replace(items); - set.commit(); - const committed = performance.now() - started; - while ( - !window.__bench.centreMagenta() && - performance.now() - started < 120000 - ) - await window.__bench.frame(); - window.__bench.set = set; - const drawnMs = performance.now() - started; - for (let index = 0; index < 10; index++) await window.__bench.frame(); - return { commitMs: committed, drawnMs }; - }, - kind, - count, - shape, - ); - // Right after the build, before tiles stream again. - const loaded = await memory(page, cdp); - const passBytes = await page.evaluate( - () => window.__bench.engine.groundDiagnostics?.()?.textureBytes ?? null, - ); - await settleTiles(page); - const frames = { - still: await page.evaluate(() => window.__bench.frameTimes(90)), - turning: await page.evaluate(() => window.__bench.frameTimes(90, true)), - }; - if (out) - await page.screenshot({ - path: path.join(out, `dubai-${shape}-${kind}-${count}.png`), - }); - await page.evaluate(async () => { - window.__bench.set.destroy(); - for (let index = 0; index < 20; index++) await window.__bench.frame(); - }); - const released = await memory(page, cdp); - const row = { - path: kind, - shape, - count, - ...built, - ...frames, - jsMB: mb(loaded.js - before.js), - gpuMB: mb(loaded.gpu - before.gpu), - navaraGpuMB: - loaded.navara == null ? null : mb(loaded.navara - before.navara), - passTexturesMB: passBytes == null ? null : mb(passBytes), - jsAfterDestroyMB: mb(released.js - before.js), - gpuAfterDestroyMB: mb(released.gpu - before.gpu), - }; - rows.push(row); - console.log( - ` N=${count}: drawn ${row.drawnMs.toFixed(0)} ms (commit ${row.commitMs.toFixed(0)}), frame still ${frames.still.median.toFixed(2)} ms (empty ${emptyFrames.still.median.toFixed(2)}), turning ${frames.turning.median.toFixed(2)} ms (empty ${emptyFrames.turning.median.toFixed(2)}), JS +${row.jsMB} MB, WebGL +${row.gpuMB} MB${row.navaraGpuMB == null ? '' : `, Navara gpuBytesEst +${row.navaraGpuMB} MB`}${row.passTexturesMB == null || kind !== 'navara-pass' ? '' : `, pass textures ${row.passTexturesMB} MB`}; after destroy JS +${row.jsAfterDestroyMB}, WebGL +${row.gpuAfterDestroyMB} MB`, - ); - } - // Edge quality over terrain: one polygon on the Hajar mountains, oblique. - if (out && shape === 'polygons') { - await page.evaluate(async (kind) => { - const { engine, frame } = window.__bench; - const ground = await window.__bench.ground(kind); - const geo = await import('/src/geo/index.js'); - engine.camera.setView({ - destination: geo.cartesian3.fromDegrees(56.05, 24.98, 9000), - orientation: { heading: 0.5, pitch: -0.45, roll: 0 }, - }); - const set = ground.createSet({ id: 'bench-terrain', kind: 'polygon' }); - const ring = []; - for (let k = 0; k < 24; k++) { - const angle = (k / 24) * 2 * Math.PI; - ring.push([ - 56.12 + 0.03 * Math.cos(angle), - 25.07 + 0.022 * Math.sin(angle), - ]); - } - set.replace([ - { - rings: [ring], - fill: [1, 0.3, 0, 0.45], - outline: { color: [1, 1, 0, 1], widthPx: 2 }, - }, - ]); - set.commit(); - for (let index = 0; index < 20; index++) await frame(); - }, kind); - await settleTiles(page); - await page.screenshot({ path: path.join(out, `terrain-${kind}.png`) }); - } - await page.close(); -} - -const browser = await puppeteer.launch({ headless: true }); -try { - for (const kind of paths) await measure(browser, kind); -} finally { - await browser.close(); -} -if (out) - fs.writeFileSync( - path.join(out, `bench-${shape}.json`), - JSON.stringify(rows, null, 2), - ); diff --git a/scripts/qa-ground-layer-switch.mjs b/scripts/qa-ground-layer-switch.mjs index 0f792a9c0..af368358f 100644 --- a/scripts/qa-ground-layer-switch.mjs +++ b/scripts/qa-ground-layer-switch.mjs @@ -218,8 +218,7 @@ await strict.run({}, async (b) => { await waitForEngine(p, 'navara'); await p.evaluate(() => { const d = window.__godsEyeView; - if (d.tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Keyless required'); + window.qaAir = d.dataManager.layers.get('air-quality').module; window.qaGnss = d.dataManager.layers.get('gnss-integrity').module; }); diff --git a/scripts/qa-ground-looks.mjs b/scripts/qa-ground-looks.mjs deleted file mode 100644 index c04460370..000000000 --- a/scripts/qa-ground-looks.mjs +++ /dev/null @@ -1,290 +0,0 @@ -#!/usr/bin/env node -/** - * Compare the animated ground corridors with the Cesium drawing they - * replace, on the Cesium engine (keyless, headless): Street Traffic's jam - * heat lines (PolylineGlow, glowPower 0.25, alpha 0.55 ± 0.2 on - * sin(now / 260 ms)) and the Directions route (the annotation route's - * GevRouteFlow material, 9 px). One road and one route over Dubai, nadir, - * drawn at fixed page times (performance.now() frozen), so every variant - * shows the same phase of the pulse and the dashes: - * - * --variant=old the old drawing, rebuilt with Cesium directly: a - * GroundPolylinePrimitive with PolylineGlow, its alpha - * set from the frozen time, and the Directions route - * entity with FlowMaterialProperty; - * --variant=engine engine.ground: the jam as the traffic layer gives it - * (glow and pulse when the build has them), the route - * through the Directions display. - * - * Both drape on terrain and tiles ('both'): keyless there are no 3D tiles, - * where the traffic layer drapes its heat lines. - * - * `--engine=navara` draws the engine variant on Navara (daylight app clock; - * its frames at the live page time, 400 ms apart: Navara's frame loop stops - * on a frozen one). - * - * GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271 - * node scripts/qa-ground-looks.mjs http://localhost:4271 --variant=old --out=DIR - * node scripts/qa-ground-looks.mjs --compare=DIR_A,DIR_B - */ -import fs from 'node:fs'; -import path from 'node:path'; -import puppeteer from 'puppeteer'; -import sharp from 'sharp'; - -const args = process.argv.slice(2); -const option = (name) => - args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); -const BLOCKED_HOSTS = - /(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/; -// Page times (ms) the frames are drawn at: across one pulse (1.63 s) and -// a few dash cells. -const TIMES = [100_000, 100_400, 100_800, 101_200]; -const CROP = { left: 340, top: 200, width: 600, height: 400 }; -// Metres over the ground: the road and the route fill the crop. -const ALTITUDE = 6000; - -async function compare([a, b]) { - const rows = []; - for (const name of fs - .readdirSync(a) - .filter((file) => file.endsWith('.png'))) { - if (!fs.existsSync(path.join(b, name))) continue; - const read = (dir) => - sharp(path.join(dir, name)) - .removeAlpha() - .raw() - .toBuffer({ resolveWithObject: true }); - const [left, right] = await Promise.all([read(a), read(b)]); - let sum = 0; - let over = 0; - const pixels = left.info.width * left.info.height; - for (let i = 0; i < left.data.length; i += 3) { - let most = 0; - for (let k = 0; k < 3; k++) - most = Math.max(most, Math.abs(left.data[i + k] - right.data[i + k])); - sum += most; - if (most > 24) over++; - } - rows.push({ - image: name, - meanDiff: +(sum / pixels).toFixed(2), - over24: +(over / pixels).toFixed(4), - }); - } - console.table(rows); -} - -async function draw(origin, variant, out, engineKind) { - fs.mkdirSync(out, { recursive: true }); - const browser = await puppeteer.launch({ - headless: true, - protocolTimeout: 600_000, - }); - try { - const page = await browser.newPage(); - await page.setViewport({ width: 1280, height: 800 }); - page.on('pageerror', (error) => console.log('page error:', error.message)); - await page.setRequestInterception(true); - page.on('request', (request) => { - if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort(); - else request.continue(); - }); - await page.goto( - `${origin}/?welcome=0&engine=${engineKind}#v=2&lat=25.2&lon=55.3&alt=${ALTITUDE}&heading=0&pitch=-90&map=osm&l=`, - ); - await page.waitForFunction( - (kind) => - window.__godsEyeView?.engine?.kind === kind && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 300_000, polling: 250 }, - engineKind, - ); - await page.evaluate( - async (variant, altitude) => { - const { engine } = window.__godsEyeView; - const { appClock } = await import('/src/time/clock.js'); - appClock.freeze(Date.UTC(2026, 10, 7, 8, 0)); - const frame = () => - new Promise((resolve) => { - const remove = engine.frames.postRender.addEventListener(() => { - remove(); - resolve(); - }); - engine.render.request('qa-ground-looks'); - }); - const geo = await import('/src/geo/index.js'); - engine.camera.setView({ - destination: geo.cartesian3.fromDegrees(55.3, 25.2, altitude), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - // Tiles in. - const started = performance.now(); - let loaded = 0; - while (performance.now() - started < 60_000 && loaded < 30) { - await frame(); - loaded = engine.surface.tilesLoaded() ? loaded + 1 : 0; - } - // A jam road across the view, and a route with turns below it. - const road = [ - [55.288, 25.204], - [55.296, 25.206], - [55.304, 25.203], - [55.312, 25.205], - ]; - const route = [ - [55.286, 25.196], - [55.294, 25.197], - [55.297, 25.192], - [55.306, 25.193], - [55.314, 25.196], - ]; - const policy = await import('/src/layers/traffic/policy.js'); - const jamRgba = policy.HEAT_JAM_COLOR.rgba(); - if (variant === 'old') { - // The app's own Cesium module: the URL the dev server gave the - // engine's import of it. - const served = await ( - await fetch('/src/globe/cesium/ground.js') - ).text(); - const Cesium = await import( - served.match(/from\s+["']([^"']*cesium[^"']*)["']/)[1] - ); - const { FlowMaterialProperty, ensureFlowFabricRegistered } = - await import('/src/globe/cesium/annotations/worldAnnotationRenderer.js'); - const { viewer } = engine.extensions.cesium; - const color = new Cesium.Color(...jamRgba.slice(0, 3), 0.55); - const jam = viewer.scene.groundPrimitives.add( - new Cesium.GroundPolylinePrimitive({ - geometryInstances: [ - new Cesium.GeometryInstance({ - geometry: new Cesium.GroundPolylineGeometry({ - positions: Cesium.Cartesian3.fromDegreesArray(road.flat()), - width: 9, - }), - }), - ], - classificationType: Cesium.ClassificationType.BOTH, - appearance: new Cesium.PolylineMaterialAppearance({ - material: Cesium.Material.fromType('PolylineGlow', { - color, - glowPower: 0.25, - }), - }), - }), - ); - ensureFlowFabricRegistered(); - viewer.entities.add({ - id: 'qa-route', - polyline: { - positions: Cesium.Cartesian3.fromDegreesArray(route.flat()), - width: 9, - material: new FlowMaterialProperty('#39d0ff'), - clampToGround: true, - classificationType: Cesium.ClassificationType.BOTH, - }, - }); - // The traffic animation set the jam's alpha every frame. - engine.frames.preRender.addEventListener(() => { - jam.appearance.material.uniforms.color.alpha = - policy.HEAT_JAM_BASE_ALPHA + - policy.HEAT_JAM_PULSE_ALPHA * Math.sin(performance.now() / 260); - }); - window.__qaReady = () => jam.ready; - } else { - const { createDirectionsDisplay } = - await import('/src/layers/directions/render/display.js'); - const animated = policy.HEAT_JAM_GLOW_POWER !== undefined; - const set = engine.ground.createSet({ - id: 'qa-jam', - kind: 'corridor', - drape: 'both', - }); - // As src/layers/traffic/rendering.js gives the jam bucket. - set.replace([ - { - positions: road, - widthPx: policy.HEAT_LINE_JAM_WIDTH, - ...(animated - ? { - color: [...jamRgba.slice(0, 3), 1], - glow: { power: policy.HEAT_JAM_GLOW_POWER }, - pulse: { - periodMs: policy.HEAT_JAM_PULSE_PERIOD_MS, - min: - policy.HEAT_JAM_BASE_ALPHA - - policy.HEAT_JAM_PULSE_ALPHA, - max: - policy.HEAT_JAM_BASE_ALPHA + - policy.HEAT_JAM_PULSE_ALPHA, - }, - } - : { - color: [...jamRgba.slice(0, 3), policy.HEAT_JAM_BASE_ALPHA], - }), - }, - ]); - set.commit(); - const display = createDirectionsDisplay(engine); - display.setRoute(route); - window.__qaReady = () => set.ready(); - } - const until = Date.now() + 60_000; - while (!window.__qaReady() && Date.now() < until) await frame(); - loaded = 0; - while (Date.now() < until + 60_000 && loaded < 30) { - await frame(); - loaded = engine.surface.tilesLoaded() ? loaded + 1 : 0; - } - }, - variant, - ALTITUDE, - ); - for (const time of TIMES) { - await page.evaluate(async (time) => { - const { engine } = window.__godsEyeView; - // Navara's frame loop stops on a frozen page clock: there the frames - // are drawn at the live time, 400 ms apart. - if (engine.kind === 'navara') - await new Promise((resolve) => setTimeout(resolve, 400)); - else performance.now = () => time; - for (let k = 0; k < 4; k++) - await new Promise((resolve) => { - const remove = engine.frames.postRender.addEventListener(() => { - remove(); - resolve(); - }); - engine.render.request('qa-ground-looks'); - }); - }, time); - await page.screenshot({ - path: path.join(out, `looks-${time}.png`), - clip: { - x: CROP.left, - y: CROP.top, - width: CROP.width, - height: CROP.height, - }, - }); - } - } finally { - await browser.close(); - } -} - -const pair = option('compare'); -if (pair) await compare(pair.split(',')); -else { - const origin = args.find((arg) => !arg.startsWith('--')); - if (!origin) { - console.error( - 'Usage: node scripts/qa-ground-looks.mjs --variant=old|engine --out=DIR | --compare=A,B', - ); - process.exit(2); - } - const variant = option('variant') ?? 'engine'; - // The old drawing is Cesium's own; engine.ground also draws on Navara. - const engineKind = - variant === 'old' ? 'cesium' : (option('engine') ?? 'cesium'); - await draw(origin, variant, option('out') ?? 'qa-ground-looks', engineKind); -} diff --git a/scripts/qa-ground-switch.mjs b/scripts/qa-ground-switch.mjs deleted file mode 100644 index 8f2d95ba1..000000000 --- a/scripts/qa-ground-switch.mjs +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env node -/** - * Show and hide of ground fills on Navara before and after an engine switch - * (Navara → Cesium → Navara, the parked view taken up again): Navara 0.2.1 - * dropped the first `show: false` of a vector layer added right after a - * switch. For engine.ground's screen pass and the vector-layer prototype - * (hidden by layer.update with show false, or by deleting its layers): a - * magenta cell hidden right after its commit must not show, then show, then - * hide again. The pixels are read from the engine canvas. - * - * GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271 - * node scripts/qa-ground-switch.mjs http://localhost:4271 - */ -import puppeteer from 'puppeteer'; -import { pickEngine, waitForEngine } from './qa-strict-harness.mjs'; - -const BLOCKED_HOSTS = - /(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/; -const origin = process.argv[2]; -if (!origin) { - console.error('Usage: node scripts/qa-ground-switch.mjs '); - process.exit(2); -} -const APPROACHES = ['pass', 'vector-update', 'vector-delete']; - -async function trial(page, approach, round) { - return page.evaluate( - async (approach, round) => { - const { engine } = window.__godsEyeView; - const geo = await import('/src/geo/index.js'); - const frame = () => - new Promise((resolve) => { - const remove = engine.frames.postRender.addEventListener(() => { - remove(); - resolve(); - }); - engine.render.request('ground-switch'); - }); - let ground = engine.ground; - let owner = null; - if (approach !== 'pass') { - const { createNavaraVectorGround } = - await import('/src/globe/navara/ground/vector.js'); - owner = createNavaraVectorGround(engine.extensions.navara.view, { - hide: approach === 'vector-update' ? 'update' : 'delete', - }); - ground = owner.ground; - } - const lon = 20 + round * 1.5 + APPROACH_INDEX[approach] * 0.5; - const spot = engine.projector.worldToWindow( - geo.cartesian3.fromDegrees(lon + 0.15, 10), - ); - const magenta = () => { - const canvas = engine.host.canvas; - const copy = document.createElement('canvas'); - copy.width = 16; - copy.height = 16; - const context = copy.getContext('2d'); - const scale = canvas.width / canvas.clientWidth; - context.drawImage( - canvas, - (spot.x - 8) * scale, - (spot.y - 8) * scale, - 16 * scale, - 16 * scale, - 0, - 0, - 16, - 16, - ); - const data = context.getImageData(0, 0, 16, 16).data; - let lit = 0; - for (let i = 0; i < data.length; i += 4) - if ( - data[i] > 150 && - data[i + 2] > 150 && - data[i + 1] < Math.min(data[i], data[i + 2]) - 60 - ) - lit++; - return lit / 256; - }; - // Frames until `want` (shown or not) holds, up to 4 s. - const until = async (want) => { - const started = performance.now(); - while (performance.now() - started < 4000) { - await frame(); - if (magenta() > 0.5 === want) return true; - } - return false; - }; - const set = ground.createSet({ - id: `switch-${approach}-${round}`, - kind: 'cell', - }); - set.replace([ - { - west: lon, - south: 9.85, - east: lon + 0.3, - north: 10.15, - fill: [1, 0, 1, 1], - }, - ]); - set.commit(); - set.show(false); - // Hidden right after the commit: never drawn. - let drawnWhileHidden = false; - for (let index = 0; index < 90; index++) { - await frame(); - if (magenta() > 0.5) drawnWhileHidden = true; - } - set.show(true); - const shows = await until(true); - set.show(false); - const hides = await until(false); - set.destroy(); - owner?.destroy(); - return { hiddenAfterCommit: !drawnWhileHidden, shows, hides }; - }, - approach, - round, - ); -} - -const browser = await puppeteer.launch({ headless: true }); -let failed = false; -try { - const page = await browser.newPage(); - await page.setViewport({ width: 1280, height: 800 }); - page.on('pageerror', (error) => console.log(' page error:', error.message)); - await page.setRequestInterception(true); - page.on('request', (request) => { - if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort(); - else request.continue(); - }); - await page.evaluateOnNewDocument((approaches) => { - window.APPROACH_INDEX = Object.fromEntries( - approaches.map((name, index) => [name, index]), - ); - }, APPROACHES); - await page.goto( - `${origin}/?welcome=0&engine=navara#v=2&lat=10&lon=21&alt=900000&heading=0&pitch=-90&map=osm&l=`, - ); - await waitForEngine(page, 'navara', { timeout: 300_000 }); - await page.evaluate(async () => { - const { appClock } = await import('/src/time/clock.js'); - appClock.freeze(Date.UTC(2026, 10, 7, 11, 0)); - }); - const report = (label, result) => { - const ok = result.hiddenAfterCommit && result.shows && result.hides; - if (!ok) failed = true; - console.log( - ` ${label}: hidden right after commit ${result.hiddenAfterCommit ? 'yes' : 'NO'}, shows ${result.shows ? 'yes' : 'NO'}, hides again ${result.hides ? 'yes' : 'NO'}`, - ); - }; - console.log('before a switch:'); - for (const approach of APPROACHES) - report(approach, await trial(page, approach, 0)); - await pickEngine(page, 'cesium'); - await waitForEngine(page, 'cesium', { timeout: 300_000 }); - await pickEngine(page, 'navara'); - await waitForEngine(page, 'navara', { timeout: 300_000 }); - await page.evaluate(async () => { - const geo = await import('/src/geo/index.js'); - window.__godsEyeView.engine.camera.setView({ - destination: geo.cartesian3.fromDegrees(21, 10, 900000), - }); - }); - console.log('after Navara → Cesium → Navara:'); - for (const approach of APPROACHES) - report(approach, await trial(page, approach, 1)); -} finally { - await browser.close(); -} -process.exit(failed ? 1 : 0); diff --git a/scripts/qa-imagery-compare.mjs b/scripts/qa-imagery-compare.mjs deleted file mode 100644 index 19614c844..000000000 --- a/scripts/qa-imagery-compare.mjs +++ /dev/null @@ -1,438 +0,0 @@ -#!/usr/bin/env node -/** - * Draw the rasters that moved to engine.imagery — the Active Fires far-zoom - * heatmap and Recent Imagery — on both real engines, read the canvas back, - * and compare two runs pixel by pixel (before and after a change), and the - * two engines against each other. Run it against a keyless dev server of - * each version, then compare: - * - * GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291 - * node scripts/qa-imagery-compare.mjs shoot http://localhost:4291 --out=DIR - * node scripts/qa-imagery-compare.mjs compare BEFORE_DIR AFTER_DIR - * - * Every scene freezes the app clock at local noon (Navara lights the scene - * with the app clock's sun) and waits for the map tiles. The heatmap is a - * fixed set of fires through the layer's own raster builder; Recent Imagery - * draws generated tiles (a gradient per tile, intercepted at qa-tiles.test) - * through the layer's renderer from the registry, falling back to the - * renderers of older versions. Each scene is shot with and without the - * raster: `overlay` is where the raster changed the frame. Google and - * Cesium ion are blocked, so no engine draws photoreal tiles. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import puppeteer from 'puppeteer'; -import sharp from 'sharp'; - -const BLOCKED_HOSTS = - /(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/; -const TILE_HOST = 'qa-tiles.test'; -const args = process.argv.slice(2); -const [command, first, second] = args.filter((arg) => !arg.startsWith('--')); -const option = (name) => - args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); -const SIZE = { width: 1280, height: 800 }; - -// Fires on three continents, a dense cluster and a sparse field each. -const FIRES = [ - ...Array.from({ length: 400 }, (_, i) => ({ - lat: 8 + (i % 20) * 0.08, - lon: 18 + Math.floor(i / 20) * 0.09, - frp: 40 + (i % 7) * 30, - })), - ...Array.from({ length: 120 }, (_, i) => ({ - lat: -12 + (i % 12) * 0.9, - lon: 22 + Math.floor(i / 12) * 1.1, - frp: 8 + (i % 5) * 6, - })), - ...Array.from({ length: 60 }, (_, i) => ({ - lat: 4 + (i % 6) * 0.5, - lon: 30 + Math.floor(i / 6) * 0.4, - frp: 200, - })), -]; - -const SCENES = [ - { - id: 'heat-orbit', - kind: 'heat', - view: { lon: 22, lat: 2, height: 12_000_000 }, - noonUtc: 10.5, - }, - { - id: 'heat-regional', - kind: 'heat', - view: { lon: 19, lat: 8.8, height: 1_500_000 }, - noonUtc: 10.5, - }, - { - id: 'imagery-box', - kind: 'imagery', - view: { lon: -97.75, lat: 30.25, height: 16_000 }, - noonUtc: 18.5, - slots: [{ id: 'a', product: 'S30', alpha: 1, split: 'none' }], - }, - { - id: 'imagery-swipe', - kind: 'imagery', - view: { lon: -97.75, lat: 30.25, height: 16_000 }, - noonUtc: 18.5, - split: 0.5, - slots: [ - { id: 'a', product: 'S30', alpha: 0.7, split: 'left' }, - { id: 'b', product: 'VIIRS', alpha: 1, split: 'right' }, - ], - }, -]; -const BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 }; - -// A 256 px tile: red across, green down, blue by the tile's parity; the -// VIIRS product (JPEG on GIBS) is a darker variant. -const tileCache = new Map(); -async function tile(z, x, y, dark) { - const key = `${z}/${x}/${y}/${dark}`; - if (tileCache.has(key)) return tileCache.get(key); - const data = Buffer.alloc(256 * 256 * 4); - const parity = (x + y) % 2 ? 200 : 40; - for (let row = 0; row < 256; row++) - for (let column = 0; column < 256; column++) { - const i = (row * 256 + column) * 4; - const scale = dark ? 0.6 : 1; - data[i] = column * scale; - data[i + 1] = row * scale; - data[i + 2] = parity * scale; - data[i + 3] = 255; - } - const png = await sharp(data, { - raw: { width: 256, height: 256, channels: 4 }, - }) - .png() - .toBuffer(); - tileCache.set(key, png); - return png; -} - -async function shootScene(browser, origin, kind, scene) { - const page = await browser.newPage(); - await page.setViewport(SIZE); - const errors = []; - page.on('pageerror', (error) => errors.push(error.message)); - await page.setRequestInterception(true); - let tiles = 0; - page.on('request', async (request) => { - const url = new URL(request.url()); - if (url.hostname === TILE_HOST) { - tiles++; - const [, product, z, y, x] = url.pathname.match( - /^\/(\w+)\/(\d+)\/(\d+)\/(\d+)\.png$/, - ); - await request.respond({ - status: 200, - contentType: 'image/png', - headers: { 'Access-Control-Allow-Origin': '*' }, - body: await tile(+z, +x, +y, product === 'VIIRS'), - }); - } else if (BLOCKED_HOSTS.test(url.hostname)) request.abort(); - else request.continue(); - }); - const { lon, lat, height } = scene.view; - await page.goto( - `${origin}/?welcome=0&engine=${kind}#v=2&lat=${lat}&lon=${lon}&alt=${height}&heading=0&pitch=-90&map=osm&l=`, - ); - await page.waitForFunction( - (expected) => - window.__godsEyeView?.engine?.kind === expected && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 300_000, polling: 250 }, - kind, - ); - await page.waitForFunction( - () => !window.__godsEyeView.engine.camera.flightActive(), - { timeout: 60_000, polling: 250 }, - ); - // The scene: the view, noon, and helpers the shots below use. - await page.evaluate( - async ({ scene, fires, box, tileHost }) => { - const { engine } = window.__godsEyeView; - const geo = await import('/src/geo/index.js'); - const { appClock } = await import('/src/time/clock.js'); - const noon = Date.UTC(2026, 10, 7) + scene.noonUtc * 3_600_000; - appClock.freeze(noon); - engine.camera.setView({ - destination: geo.cartesian3.fromDegrees( - scene.view.lon, - scene.view.lat, - scene.view.height, - ), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - // `count` frames, each asked for (a scene at rest draws only on request). - const frames = (count) => - new Promise((resolve) => { - let left = count; - const remove = engine.frames.postRender.addEventListener(() => { - if (--left > 0) { - setTimeout(() => engine.render.request('qa-imagery'), 0); - return; - } - remove(); - resolve(); - }); - engine.render.request('qa-imagery'); - }); - const { rendererFor } = await import('/src/app/layers/renderers.js'); - const qa = { frames, raster: null }; - window.__qaImagery = qa; - if (scene.kind === 'heat') { - const [{ buildHeatRaster }, { HEAT_RASTER }] = await Promise.all([ - import('/src/data/firmsHeatRaster.js'), - import('/src/layers/firms/policy.js'), - ]); - const display = rendererFor('local-firms', engine, { id: 'qa-firms' }); - const heat = display.createHeatImagery(); - const raster = buildHeatRaster(fires, HEAT_RASTER); - qa.show = () => { - heat.setRaster(raster); - heat.setOpacity(1); - heat.setShow(true); - }; - qa.hide = () => heat.setShow(false); - return; - } - const options = { - tileTemplate: (product) => - `https://${tileHost}/${product}/{z}/{y}/{x}.png`, - credit: 'QA tiles', - }; - let renderer = rendererFor('recent-imagery', engine, options); - const viewer = engine.extensions?.cesium?.viewer; - if (!renderer) { - // Versions before engine.imagery: Cesium's own renderer on the globe. - const { createRecentImageryRenderer } = - await import('/src/layers/recentImagery/rendering.js'); - renderer = createRecentImageryRenderer({ - ...options, - requestRender: () => engine.render.request('qa-imagery'), - }); - renderer.rebind({ collection: viewer.imageryLayers, kind: 'globe' }); - } else renderer.rebind(renderer.resolveHost()); - const candidate = (product) => ({ - key: `${product}:2026-10-28`, - product, - day: '2026-10-28', - }); - qa.show = () => { - if (scene.split != null) { - renderer.setSplit?.(scene.split); - if (viewer) viewer.scene.splitPosition = scene.split; - } - for (const slot of scene.slots) - renderer.showSlot(slot.id, candidate(slot.product), box, { - alpha: slot.alpha, - splitDirection: slot.split, - }); - }; - qa.hide = () => { - for (const slot of scene.slots) renderer.hideSlot(slot.id); - }; - }, - { scene, fires: FIRES, box: BOX, tileHost: TILE_HOST }, - ); - // Settle: the map tiles loaded, then tile requests quiet for 2 s. - const settle = async () => { - for (let round = 0; round < 120; round++) { - const before = tiles; - await page.evaluate(() => window.__qaImagery.frames(10)); - await new Promise((resolve) => setTimeout(resolve, 500)); - const loaded = await page.evaluate(() => - window.__godsEyeView.engine.surface.tilesLoaded(), - ); - if (loaded && tiles === before && round >= 4) break; - } - await page.evaluate(() => window.__qaImagery.frames(20)); - }; - const read = () => - page.evaluate(() => { - const { engine } = window.__godsEyeView; - const canvas = engine.host.canvas; - const scratch = document.createElement('canvas'); - scratch.width = canvas.clientWidth; - scratch.height = canvas.clientHeight; - const context = scratch.getContext('2d'); - context.drawImage(canvas, 0, 0, scratch.width, scratch.height); - return scratch.toDataURL('image/png'); - }); - await settle(); - const without = await read(); - await page.evaluate(() => window.__qaImagery.show()); - await settle(); - const withRaster = await read(); - await page.close(); - return { without, with: withRaster, errors }; -} - -async function shoot(origin, out) { - fs.mkdirSync(out, { recursive: true }); - const browser = await puppeteer.launch({ - headless: true, - protocolTimeout: 600_000, - args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'], - }); - const engines = (option('engine') ?? 'cesium,navara').split(','); - const only = option('only')?.split(',') ?? null; - try { - for (const kind of engines) - for (const scene of SCENES) { - if (only && !only.includes(scene.id)) continue; - const shot = await shootScene(browser, origin, kind, scene); - for (const name of ['without', 'with']) - fs.writeFileSync( - path.join(out, `${kind}-${scene.id}-${name}.png`), - Buffer.from(shot[name].split(',')[1], 'base64'), - ); - console.log( - `${kind} ${scene.id}: shot${shot.errors.length ? ` (page errors: ${shot.errors.join('; ')})` : ''}`, - ); - } - } finally { - await browser.close(); - } -} - -const raw = async (file) => - (await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true })) - .data; - -// Where the raster changed the frame (any channel by more than 6). -function overlayMask(without, withRaster) { - const mask = new Uint8Array(without.length / 4); - for (let p = 0; p < mask.length; p++) - for (let k = 0; k < 3; k++) - if (Math.abs(without[p * 4 + k] - withRaster[p * 4 + k]) > 6) { - mask[p] = 1; - break; - } - return mask; -} - -// Per-pixel differences of two frames inside `mask` (or everywhere). -function difference(a, b, mask) { - let count = 0, - sum = 0, - over8 = 0, - over24 = 0, - max = 0; - for (let p = 0; p < a.length / 4; p++) { - if (mask && !mask[p]) continue; - let worst = 0; - for (let k = 0; k < 3; k++) - worst = Math.max(worst, Math.abs(a[p * 4 + k] - b[p * 4 + k])); - count++; - sum += worst; - if (worst > 8) over8++; - if (worst > 24) over24++; - max = Math.max(max, worst); - } - return { - pixels: count, - meanMaxChannel: count ? +(sum / count).toFixed(2) : 0, - over8: count ? +((100 * over8) / count).toFixed(2) : 0, - over24: count ? +((100 * over24) / count).toFixed(2) : 0, - max, - }; -} - -// The raster's own change of the frame: with minus without, per channel. -function contribution(without, withRaster) { - const out = new Int16Array(without.length); - for (let i = 0; i < without.length; i++) out[i] = withRaster[i] - without[i]; - return out; -} - -async function compare(beforeDir, afterDir) { - const report = {}; - const engines = ['cesium', 'navara']; - for (const scene of SCENES) { - const row = (report[scene.id] = {}); - const loaded = {}; - for (const engine of engines) - for (const [version, dir] of [ - ['before', beforeDir], - ['after', afterDir], - ]) - for (const name of ['without', 'with']) { - const file = path.join(dir, `${engine}-${scene.id}-${name}.png`); - if (fs.existsSync(file)) - loaded[`${engine}-${version}-${name}`] = await raw(file); - } - for (const engine of engines) { - const before = loaded[`${engine}-before-with`]; - const after = loaded[`${engine}-after-with`]; - if (!before || !after) continue; - const mask = overlayMask(loaded[`${engine}-before-without`], before); - row[`${engine}: before vs after, where the raster draws`] = difference( - before, - after, - mask, - ); - row[`${engine}: before vs after, whole frame`] = difference( - before, - after, - ); - row[`${engine}: base map before vs after`] = difference( - loaded[`${engine}-before-without`], - loaded[`${engine}-after-without`], - ); - } - // Navara against Cesium: the raster's change of each frame (the base - // maps are drawn differently), where Cesium's raster draws. - const reference = loaded['cesium-after-with']; - const navara = loaded['navara-after-with']; - if (reference && navara) { - const mask = overlayMask(loaded['cesium-after-without'], reference); - const a = contribution(loaded['cesium-after-without'], reference); - const b = contribution(loaded['navara-after-without'], navara); - const shift = (values) => - Uint8Array.from(values, (value) => - Math.max(0, Math.min(255, value + 128)), - ); - row['navara vs cesium after: the raster’s change of the frame'] = - difference(shift(a), shift(b), mask); - row['navara vs cesium after: frames with the raster'] = difference( - reference, - navara, - mask, - ); - const beforeReference = loaded['cesium-before-with']; - const beforeNavara = loaded['navara-before-with']; - if (beforeReference && beforeNavara) - row['navara vs cesium before: frames with the raster'] = difference( - beforeReference, - beforeNavara, - overlayMask(loaded['cesium-before-without'], beforeReference), - ); - } - } - return report; -} - -if (command === 'shoot' && first) { - await shoot(first, option('out') ?? 'qa-imagery'); -} else if (command === 'compare' && first && second) { - const report = await compare(first, second); - const json = option('json'); - if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`); - for (const [scene, rows] of Object.entries(report)) { - console.log(scene); - for (const [label, stats] of Object.entries(rows)) - console.log( - ` ${label}: ${stats.pixels} px, mean ${stats.meanMaxChannel}, >8 ${stats.over8}%, >24 ${stats.over24}%, max ${stats.max}`, - ); - } -} else { - console.error( - 'Usage: node scripts/qa-imagery-compare.mjs shoot [--out=DIR] [--engine=cesium,navara] [--only=scene,...]\n' + - ' node scripts/qa-imagery-compare.mjs compare [--json=FILE]', - ); - process.exit(2); -} diff --git a/scripts/qa-infrastructure-engine-switch.mjs b/scripts/qa-infrastructure-engine-switch.mjs index 7d47def14..54563f6f5 100644 --- a/scripts/qa-infrastructure-engine-switch.mjs +++ b/scripts/qa-infrastructure-engine-switch.mjs @@ -54,9 +54,8 @@ await strict.run({}, async (b) => { await waitForEngine(p, 'navara'); const ids = ['local-datacenters', 'local-dams']; const dataset = await p.evaluate(async (ids) => { - const { dataManager, tileset } = window.__godsEyeView; - if (tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Keyless preview required'); + const { dataManager } = window.__godsEyeView; + window.qaSites = []; const records = []; for (const id of ids) { diff --git a/scripts/qa-layer-handoff.mjs b/scripts/qa-layer-handoff.mjs index e217b69a1..5f0e6aa76 100644 --- a/scripts/qa-layer-handoff.mjs +++ b/scripts/qa-layer-handoff.mjs @@ -1,3 +1,4 @@ +import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import { createStrict, localPreview } from './qa-strict-harness.mjs'; @@ -125,10 +126,10 @@ await strict.run({}, async (browser) => { await page.waitForFunction( () => window.__godsEyeView.styleManager.getCockpitState().active, ); + await assertKeylessPreview(page); await page.evaluate(() => { - const { dataManager, styleManager, tileset } = window.__godsEyeView; - if (window.__GOOGLE_MAPS_API_KEY__ || tileset) - throw Error('Keyless preview required'); + const { dataManager, styleManager } = window.__godsEyeView; + styleManager.cockpitView.setVisionMode('thermal'); const entry = dataManager.layers.get(window.__qaAircraft.layerId); window.__handoff = { diff --git a/scripts/qa-local-adsb-engine-switch.mjs b/scripts/qa-local-adsb-engine-switch.mjs index 7504daf18..211125592 100644 --- a/scripts/qa-local-adsb-engine-switch.mjs +++ b/scripts/qa-local-adsb-engine-switch.mjs @@ -54,9 +54,8 @@ await strict.run({}, async (b) => { await p.goto(base.href); await waitForEngine(p, 'navara'); await p.evaluate(async () => { - const { dataManager, tileset } = window.__godsEyeView; - if (tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Keyless preview required'); + const { dataManager } = window.__godsEyeView; + const mod = dataManager.layers.get('local-adsb').module; window.qaAdsb = { mod, diff --git a/scripts/qa-map-policy.mjs b/scripts/qa-map-policy.mjs index 9fa1600d0..74af20cee 100644 --- a/scripts/qa-map-policy.mjs +++ b/scripts/qa-map-policy.mjs @@ -115,3 +115,18 @@ export function strictBlockPatterns( { urlPattern: '*://*:*/*', block: true }, ]; } + +/** Paid or live model endpoints no switch gate may reach. */ +export const SESSION_BLOCKED_URLS = Object.freeze([ + '*://tile.googleapis.com/*', + '*://*.googleapis.com/*3dtiles*', + '*://api.cesium.com/*', + '*://assets.ion.cesium.com/*', + '*://maps.googleapis.com/*', + '*/api/google/*', + '*/api/openai/*', + '*/api/realtime/*', + '*/api/gemini/*', + '*://api.openai.com/*', + '*://generativelanguage.googleapis.com/*', +]); diff --git a/scripts/qa-navara-annotations.mjs b/scripts/qa-navara-annotations.mjs index c65cfd60d..474e6c1ce 100644 --- a/scripts/qa-navara-annotations.mjs +++ b/scripts/qa-navara-annotations.mjs @@ -46,13 +46,6 @@ await strict.run( page.on('pageerror', (error) => errors.push(error.message)); await page.goto(base.href); await waitForEngine(page, 'navara'); - assert.equal( - await page.evaluate(() => - Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__), - ), - false, - 'keyless preview required', - ); /** Amber pixels of the globe canvas alone (the SVG board hidden). */ const canvasAmber = async () => { diff --git a/scripts/qa-navara-draw.mjs b/scripts/qa-navara-draw.mjs index a1cf4382a..8da65b128 100644 --- a/scripts/qa-navara-draw.mjs +++ b/scripts/qa-navara-draw.mjs @@ -8,6 +8,7 @@ // the shapes drawn on either engine stay on the board and draw on both. // // node scripts/qa-navara-draw.mjs http://127.0.0.1:/ +import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import sharp from 'sharp'; @@ -58,13 +59,7 @@ await strict.run( ); }; await ready('navara'); - assert.equal( - await page.evaluate(() => - Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__), - ), - false, - 'keyless preview required', - ); + await assertKeylessPreview(page); const tool = () => page.evaluate(() => { const draw = window.__gevDrawTool; diff --git a/scripts/qa-navara-geo-import.mjs b/scripts/qa-navara-geo-import.mjs index f8029c048..84a919d30 100644 --- a/scripts/qa-navara-geo-import.mjs +++ b/scripts/qa-navara-geo-import.mjs @@ -9,6 +9,7 @@ // outside the preview and the free tile cache). // // node scripts/qa-navara-geo-import.mjs http://127.0.0.1:/ +import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; @@ -162,13 +163,7 @@ await strict.run( ); }; await ready('navara'); - assert.equal( - await page.evaluate(() => - Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__), - ), - false, - 'keyless preview required', - ); + await assertKeylessPreview(page); const state = () => page.evaluate(() => ({ files: window.__gevGeoImport.list(), diff --git a/scripts/qa-navara-scope.mjs b/scripts/qa-navara-scope.mjs index e87d960b9..efce19245 100644 --- a/scripts/qa-navara-scope.mjs +++ b/scripts/qa-navara-scope.mjs @@ -46,13 +46,6 @@ await strict.run( page.on('pageerror', (error) => errors.push(error.message)); await page.goto(base.href); await waitForEngine(page, 'navara'); - assert.equal( - await page.evaluate(() => - Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__), - ), - false, - 'keyless preview required', - ); const edgeLuminance = async () => { await wait(900); const { data, info } = await sharp(await page.screenshot({ clip: EDGE })) diff --git a/scripts/qa-nepal-event-switch.mjs b/scripts/qa-nepal-event-switch.mjs index 48648db65..3f9aeb775 100644 --- a/scripts/qa-nepal-event-switch.mjs +++ b/scripts/qa-nepal-event-switch.mjs @@ -77,8 +77,7 @@ await strict.run({}, async (b) => { const { tileset, dataManager, sceneDirector } = window.__godsEyeView; if (!sceneDirector?.listScenes().length) throw Error('No scenes available on Navara'); - if (tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Keyless preview required'); + const mod = dataManager.layers.get('bhote-koshi-2026').module; window.qaEvent = mod; await dataManager.setEnabled(mod.id, true, { origin: 'user' }); diff --git a/scripts/qa-nepal-locator-switch.mjs b/scripts/qa-nepal-locator-switch.mjs index e236130e8..95a9aa977 100644 --- a/scripts/qa-nepal-locator-switch.mjs +++ b/scripts/qa-nepal-locator-switch.mjs @@ -21,9 +21,8 @@ await strict.run({}, async (b) => { await p.goto(base.href); await waitForEngine(p, 'navara'); await p.evaluate(async () => { - const { tileset, dataManager } = window.__godsEyeView; - if (tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Keyless preview required'); + const { dataManager } = window.__godsEyeView; + window.qaLocator = dataManager.layers.get('bhote-koshi-locator').module; window.qaLocator.setParams({ presentation: 'bhote-koshi-path-overview' }); await dataManager.setEnabled('bhote-koshi-locator', true, { diff --git a/scripts/qa-radio-engine-switch.mjs b/scripts/qa-radio-engine-switch.mjs index 6e8b688e4..8f902bcf4 100644 --- a/scripts/qa-radio-engine-switch.mjs +++ b/scripts/qa-radio-engine-switch.mjs @@ -1,27 +1,21 @@ import assert from 'node:assert/strict'; import { stations, installRadioFixture } from './fixtures/w2/radio.mjs'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { createStrict, localPreview, openPage, waitForEngine } from './qa-strict-harness.mjs'; const url = localPreview(process.argv[2]); // The long soak and switch timings live in qa-strict-soak.mjs. url.search = '?engine=navara&welcome=0'; url.hash = 'v=2&lat=30.27&lon=-97.74&alt=12000&pitch=-90&map=sentinel2&l='; const strict = createStrict({ origin: url.origin, layers: ['radio'] }); await strict.run({}, async (browser) => { - const page = await browser.newPage(); - const errors = []; - page.on('pageerror', (e) => errors.push(e.message)); + const { page: page, errors } = await openPage(browser); + await page.setViewport({ width: 800, height: 600 }); await installRadioFixture(page); await page.goto(url.href); - await page.waitForFunction( - () => - window.__godsEyeView?.engine?.kind === 'navara' && - document.querySelector('#loading-screen.hidden'), - ); + await waitForEngine(page, 'navara'); await page.evaluate(async (id) => { - const { dataManager, styleManager, tileset } = window.__godsEyeView; + const { dataManager, styleManager } = window.__godsEyeView; await styleManager.initialRestorePromise; - if (window.__GOOGLE_MAPS_API_KEY__ || tileset) - throw Error('Expected keyless preview'); + window.qaLayer = dataManager.layers.get('radio').module; await dataManager.setEnabled('radio', true, { origin: 'user' }); }, stations[0].id); diff --git a/scripts/qa-space-engine-switch.mjs b/scripts/qa-space-engine-switch.mjs index dba39241b..b00adc8c1 100644 --- a/scripts/qa-space-engine-switch.mjs +++ b/scripts/qa-space-engine-switch.mjs @@ -1,3 +1,4 @@ +import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import { launchFixture } from '../src/bench/launchFixture.js'; import { @@ -62,10 +63,10 @@ await strict.run({}, async (b) => { window.__godsEyeView?.engine?.kind === 'navara' && document.querySelector('#loading-screen.hidden'), ); + await assertKeylessPreview(p); await p.evaluate(async (missions) => { - const { dataManager, styleManager, tileset } = window.__godsEyeView; - if (tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Expected keyless preview'); + const { dataManager, styleManager } = window.__godsEyeView; + await styleManager.initialRestorePromise; window.sa = dataManager.layers.get('satellites').module; window.la = dataManager.layers.get('rocket-launches').module; diff --git a/scripts/qa-street-level-engine-switch.mjs b/scripts/qa-street-level-engine-switch.mjs index f43221e29..b2efa8f3f 100644 --- a/scripts/qa-street-level-engine-switch.mjs +++ b/scripts/qa-street-level-engine-switch.mjs @@ -57,9 +57,8 @@ await strict.run({}, async (browser) => { await p.goto(base.href); await waitForEngine(p, 'navara'); await p.evaluate(async (provider) => { - const { dataManager, tileset } = window.__godsEyeView; - if (tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Expected keyless preview'); + const { dataManager } = window.__godsEyeView; + window.qaStreet = dataManager.layers.get('street-level').module; await dataManager.setEnabled('street-level', true, { origin: 'user' }); window.qaStreet.setProviderEnabled(provider, true); diff --git a/scripts/qa-strict-harness.mjs b/scripts/qa-strict-harness.mjs index 1fbeae9d5..f88692f88 100644 --- a/scripts/qa-strict-harness.mjs +++ b/scripts/qa-strict-harness.mjs @@ -34,18 +34,11 @@ const CHOICES = Object.freeze([...ENGINES, 'navara-hd']); /** Local ports that belong to the user's own servers: never a QA preview. */ export const RESERVED_PORTS = Object.freeze([ '', - '4173', - '4174', - '4185', - '4186', - '4187', - '4188', - '4189', - '4194', - '4196', + ...(process.env.QA_RESERVED_PORTS ?? '4173,4174,4185,4186,4187,4188,4189,4194,4196') + .split(',').map((port) => port.trim()).filter(Boolean), ]); /** Chrome flags every scenario's browser starts with (the GPU path). */ -const CHROME_ARGS = Object.freeze(['--use-angle=metal', '--enable-gpu']); +export const CHROME_ARGS = Object.freeze([`--use-angle=${process.env.QA_ANGLE || 'metal'}`, '--enable-gpu']); /** The values of --layers=a,b and --evidence=FILE (null when absent). */ export function strictArguments(argv = process.argv) { @@ -69,6 +62,27 @@ export function localPreview(href) { return url; } +/** Refuse to start a preview over an existing local server. */ +export async function assertPreviewPortAvailable(href, request = fetch) { + const url = localPreview(href); + let response; + try { + response = await request(url.origin, { signal: AbortSignal.timeout(1000) }); + } catch (error) { + if (error.cause?.code === 'ECONNREFUSED') return; + throw error; + } + if (response) throw new Error(`Preview port ${url.port} already answers`); +} + +/** Check the running page before any scenario touches its layers. */ +export async function assertKeylessPreview(page) { + await page.evaluate(() => { + if (window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__) + throw new Error('Keyless preview required'); + }); +} + /** The URL with no remote IP: fulfilled by a fixture or a cache, or a data URL. */ const live = (response) => Boolean(response.remoteIPAddress); @@ -567,6 +581,7 @@ export async function waitForEngine(page, kind, { timeout = 120000 } = {}) { await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); + await assertKeylessPreview(page); } /** diff --git a/scripts/qa-strict-session.mjs b/scripts/qa-strict-session.mjs index 9609485bb..ea93273cb 100644 --- a/scripts/qa-strict-session.mjs +++ b/scripts/qa-strict-session.mjs @@ -120,8 +120,7 @@ export async function openApp( await page.goto(url.href, { waitUntil: 'domcontentloaded' }); await waitForEngine(page, engine); await page.evaluate(() => { - if (window.__GOOGLE_MAPS_API_KEY__ || window.__godsEyeView.tileset) - throw Error('Keyless preview required'); + window.__qaEngines = new Map([ [window.__godsEyeView.engine.kind, window.__godsEyeView.engine], ]); diff --git a/scripts/qa-submarine-cables-engine-switch.mjs b/scripts/qa-submarine-cables-engine-switch.mjs index a85198fe0..34939b9ac 100644 --- a/scripts/qa-submarine-cables-engine-switch.mjs +++ b/scripts/qa-submarine-cables-engine-switch.mjs @@ -42,9 +42,8 @@ await strict.run({}, async (b) => { await p.goto(base.href); await waitForEngine(p, 'navara'); await p.evaluate(async () => { - const { dataManager, tileset } = window.__godsEyeView; - if (tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Keyless preview required'); + const { dataManager } = window.__godsEyeView; + window.qaCables = dataManager.layers.get( 'telegeography-submarine-cables', ).module; diff --git a/scripts/qa-traffic-engine-switch.mjs b/scripts/qa-traffic-engine-switch.mjs index 243c5a566..da0e6bde2 100644 --- a/scripts/qa-traffic-engine-switch.mjs +++ b/scripts/qa-traffic-engine-switch.mjs @@ -75,9 +75,8 @@ await strict.run({}, async (b) => { await p.goto(base.href); await waitForEngine(p, 'navara'); await p.evaluate(async () => { - const { tileset, dataManager } = window.__godsEyeView; - if (tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Keyless preview required'); + const { dataManager } = window.__godsEyeView; + window.qaTraffic = dataManager.layers.get('traffic').module; window.qaTraffic.setParams({ jamViz: 'both' }); await dataManager.setEnabled('traffic', true, { origin: 'user' }); diff --git a/scripts/qa-urban-engine-switch.mjs b/scripts/qa-urban-engine-switch.mjs index 432068653..f9aa944b1 100644 --- a/scripts/qa-urban-engine-switch.mjs +++ b/scripts/qa-urban-engine-switch.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { fromDegrees } from '../src/geo/cartesian3.js'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { createStrict, localPreview, openPage, waitForEngine } from './qa-strict-harness.mjs'; import { installTransitFixture } from './fixtures/w2/transit.mjs'; import { combinedPayloads, @@ -22,37 +22,19 @@ const VEHICLES = 12; const strict = createStrict({ origin: url.origin, layers: [id], - // installCombinedFixture answers these hosts too. - fixtures: bus - ? [] - : [ - 'https://earthquake.usgs.gov/*', - 'https://tiles.dontgetflocked.com/*', - '*://*.earthdata.nasa.gov/*', - ], }); await strict.run({}, async (b) => { - const p = await b.newPage(); + const { page: p, errors } = await openPage(b); await p.setViewport({ width: 1280, height: 900 }); - const errors = []; - p.on('pageerror', (e) => { - errors.push(e.message); - console.log('error', e.message); - }); const fx = bus ? await installTransitFixture(p) : await installCombinedFixture(p); fx.setStep?.(9); await p.goto(url.href); - await p.waitForFunction( - () => - window.__godsEyeView?.engine?.kind === 'navara' && - document.querySelector('#loading-screen.hidden'), - ); + await waitForEngine(p, 'navara'); await p.evaluate(async (id) => { - const { dataManager, styleManager, tileset } = window.__godsEyeView; - if (tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Expected keyless preview'); + const { dataManager, styleManager } = window.__godsEyeView; + await styleManager.initialRestorePromise; window.qaUrban = dataManager.layers.get(id).module; await dataManager.setEnabled(id, true, { origin: 'user' }); diff --git a/scripts/qa-voice-engine-switch.mjs b/scripts/qa-voice-engine-switch.mjs index de25d7a52..c93f550d4 100644 --- a/scripts/qa-voice-engine-switch.mjs +++ b/scripts/qa-voice-engine-switch.mjs @@ -1,3 +1,4 @@ +import { localPreview, waitForEngine } from './qa-strict-harness.mjs'; // An active voice session survives engine switches. With a fake microphone // and a fake Realtime transport (no model is reached: /api/openai, /api/realtime // and api.openai.com are blocked or answered in the page), this gate starts @@ -17,16 +18,14 @@ import { idDifference, launch, openSessionPage, - previewBase, readSession, readVoiceEvidence, requestEngine, sessionAddress, switchEngine, - waitReady, } from './qa/appSessionHarness.mjs'; -const base = previewBase(process.argv[2]); +const base = localPreview(process.argv[2]); // Play one server function call on the fake data channel and wait for the // client's function_call_output. @@ -117,7 +116,7 @@ try { browser, sessionAddress(base, { engine: 'cesium' }), ); - await waitReady(page, 'cesium'); + await waitForEngine(page, 'cesium', { timeout: 90_000 }); await page.evaluate(async () => { const close = document.querySelector('[data-key-setup-close]'); if (close?.offsetParent) close.click(); @@ -184,7 +183,7 @@ try { { layerId: 'earthquakes', enabled: true }, { beforeCall: 'switch-cesium' }, ); - await waitReady(page, 'cesium'); + await waitForEngine(page, 'cesium', { timeout: 90_000 }); assert.equal(midSwitch.ok, true, JSON.stringify(midSwitch)); assert.equal(midSwitch.engine, 'cesium', JSON.stringify(midSwitch)); results.push('PASS a call made during the switch ran on the incoming engine'); @@ -204,7 +203,7 @@ try { { layerId: 'earthquakes', enabled: false }, { beforeCall: 'switch-navara' }, ); - await waitReady(page, 'navara'); + await waitForEngine(page, 'navara', { timeout: 90_000 }); await page.evaluate(() => { window.__godsEyeView.voiceCommands.runner.switchWaitMs = 15_000; }); diff --git a/scripts/qa-weather-compare.mjs b/scripts/qa-weather-compare.mjs deleted file mode 100644 index 10ecf939a..000000000 --- a/scripts/qa-weather-compare.mjs +++ /dev/null @@ -1,644 +0,0 @@ -#!/usr/bin/env node -/** - * Draw the rasters that moved to engine.imagery's tile schemes, staged - * sources and raised layers — the rain radar, lightning density, regional - * and global satellite clouds, the wind field and the Bhote Koshi - * before/after split — on both real engines, read the canvas back, and - * compare two runs pixel by pixel (before and after a change), and the two - * engines against each other. Run it against a keyless dev server of each - * version, then compare: - * - * GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291 - * node scripts/qa-weather-compare.mjs shoot http://localhost:4291 --out=DIR - * node scripts/qa-weather-compare.mjs compare BEFORE_DIR AFTER_DIR - * - * The page must serve `/qa-cesium.js` (`export * from 'cesium';` at the - * root of the served copy): older versions build their Cesium renderers - * from the SDK. Every scene freezes the app clock at local noon and waits - * for the map tiles. The weather tiles and images are generated here - * (intercepted /api/weather/tile and /api/weather/image) from one field of - * longitude and latitude, so a tile and a whole-extent image of the same - * place agree; the wind is a fixed field, paused. Each scene is shot with - * and without the raster: `overlay` is where the raster changed the frame. - * Google and Cesium ion are blocked, so no engine draws photoreal tiles. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import puppeteer from 'puppeteer'; -import sharp from 'sharp'; - -const BLOCKED_HOSTS = - /(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/; -const args = process.argv.slice(2); -const [command, first, second] = args.filter((arg) => !arg.startsWith('--')); -const option = (name) => - args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); -const SIZE = { width: 1280, height: 800 }; -const TIME = '2026-11-07T12:00:00.000Z'; -const US = { west: -130, south: 20, east: -60, north: 55 }; -const GLOBE = { west: -180, south: -90, east: 180, north: 90 }; -const NEPAL = { west: 85.295, south: 28.135, east: 85.379, north: 28.292 }; - -const SCENES = [ - { - id: 'radar', - kind: 'weather', - product: 'radar', - bounds: US, - alpha: 0.8, - view: { lon: -95, lat: 37.5, height: 6_000_000 }, - noonUtc: 18, - }, - { - id: 'lightning', - kind: 'weather', - product: 'lightning', - bounds: US, - alpha: 0.8, - view: { lon: -95, lat: 37.5, height: 6_000_000 }, - noonUtc: 18, - }, - { - id: 'clouds-regional', - kind: 'weather', - product: 'clouds-regional', - bounds: US, - alpha: 0.7, - view: { lon: -95, lat: 37.5, height: 6_000_000 }, - noonUtc: 18, - }, - { - id: 'clouds-global', - kind: 'weather', - product: 'clouds', - bounds: GLOBE, - alpha: 0.7, - view: { lon: -40, lat: 15, height: 14_000_000 }, - noonUtc: 14.5, - }, - { - id: 'wind', - kind: 'wind', - view: { lon: -40, lat: 20, height: 12_000_000 }, - noonUtc: 14.5, - }, - { - id: 'nepal-split', - kind: 'nepal', - view: { lon: 85.337, lat: 28.2135, height: 22_000 }, - noonUtc: 6.5, - }, -]; - -// The weather field at a longitude and latitude: blobs with soft edges -// (radar, lightning) or a grey sky to mask (the infrared products). -function weatherPixel(product, lon, lat, out, at) { - const v = - 0.5 + 0.25 * Math.sin(lon * 0.35) + 0.25 * Math.cos(lat * 0.5 + lon * 0.1); - if (product === 'clouds' || product === 'clouds-regional') { - const grey = Math.round(255 * Math.max(0, Math.min(1, v))); - out[at] = out[at + 1] = out[at + 2] = grey; - out[at + 3] = 255; - return; - } - out[at] = Math.round(255 * Math.max(0, Math.min(1, v))); - out[at + 1] = Math.round(255 * Math.max(0, Math.min(1, 1 - v))); - out[at + 2] = product === 'lightning' ? 220 : 90; - out[at + 3] = Math.round(255 * Math.max(0, Math.min(1, (v - 0.45) * 4))); -} - -async function weatherPng(product, box, width, height) { - const data = Buffer.alloc(width * height * 4); - for (let row = 0; row < height; row++) { - const lat = box.north - ((row + 0.5) / height) * (box.north - box.south); - for (let column = 0; column < width; column++) { - const lon = box.west + ((column + 0.5) / width) * (box.east - box.west); - weatherPixel(product, lon, lat, data, (row * width + column) * 4); - } - } - return sharp(data, { raw: { width, height, channels: 4 } }) - .png() - .toBuffer(); -} - -const LARGEST = { - radar: [4096, 2048], - lightning: [4096, 2048], - 'clouds-regional': [4096, 2048], - clouds: [2048, 1024], -}; -const pngCache = new Map(); -// One generated /api/weather/tile or /api/weather/image response, or null. -async function weatherResponse(url) { - const key = url.pathname + url.search; - if (pngCache.has(key)) return pngCache.get(key); - const query = url.searchParams; - const product = query.get('product'); - const scene = SCENES.find((entry) => entry.product === product); - if (!scene) return null; - let png; - if (url.pathname === '/api/weather/tile') { - const z = +query.get('z'); - const x = +query.get('x'); - const y = +query.get('y'); - const span = 180 / 2 ** z; - const size = +(query.get('size') ?? 256); - png = await weatherPng( - product, - { - west: -180 + x * span, - east: -180 + (x + 1) * span, - north: 90 - y * span, - south: 90 - (y + 1) * span, - }, - size, - size, - ); - } else { - const bbox = query.get('bbox'); - const [west, south, east, north] = bbox - ? bbox.split(',').map(Number) - : [ - scene.bounds.west, - scene.bounds.south, - scene.bounds.east, - scene.bounds.north, - ]; - const [width, height] = query.get('size') - ? query.get('size').split('x').map(Number) - : bbox - ? [4096, 2048] - : LARGEST[product]; - png = await weatherPng( - product, - { west, south, east, north }, - width, - height, - ); - } - pngCache.set(key, png); - return png; -} - -// A fixed 5° wind field: westerlies, trades and a jet. -function windSnapshot() { - const nx = 72; - const ny = 37; - const u = new Float32Array(nx * ny); - const v = new Float32Array(nx * ny); - for (let j = 0; j < ny; j++) { - const lat = 90 - j * 5; - for (let i = 0; i < nx; i++) { - const lon = i * 5; - u[j * nx + i] = - 14 * Math.cos((lat * Math.PI) / 60) + - 6 * Math.sin((lon * Math.PI) / 45); - v[j * nx + i] = 5 * Math.sin(((lon + lat) * Math.PI) / 50); - } - } - return { - model: 'gfs', - level: '10m', - units: 'm/s', - cycle: { runIso: TIME, validIso: TIME, forecastHour: 0 }, - grid: { nx, ny, lo1: 0, la1: 90, dx: 5, dy: 5 }, - u: Array.from(u), - v: Array.from(v), - }; -} - -async function shootScene(browser, origin, kind, scene) { - const page = await browser.newPage(); - await page.setViewport(SIZE); - const errors = []; - page.on('pageerror', (error) => errors.push(error.message)); - await page.setRequestInterception(true); - let requests = 0; - page.on('request', async (request) => { - const url = new URL(request.url()); - if (url.pathname.startsWith('/api/weather/')) { - requests++; - const png = await weatherResponse(url).catch(() => null); - if (png) - await request.respond({ - status: 200, - contentType: 'image/png', - body: png, - }); - else await request.respond({ status: 404, body: '' }); - } else if (BLOCKED_HOSTS.test(url.hostname)) request.abort(); - else request.continue(); - }); - const { lon, lat, height } = scene.view; - await page.goto( - `${origin}/?welcome=0&engine=${kind}#v=2&lat=${lat}&lon=${lon}&alt=${height}&heading=0&pitch=-90&map=osm&l=`, - ); - await page.waitForFunction( - (expected) => - window.__godsEyeView?.engine?.kind === expected && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 300_000, polling: 250 }, - kind, - ); - await page.waitForFunction( - () => !window.__godsEyeView.engine.camera.flightActive(), - { timeout: 60_000, polling: 250 }, - ); - const version = await page.evaluate( - async ({ scene, time, wind }) => { - const { engine } = window.__godsEyeView; - const geo = await import('/src/geo/index.js'); - const { appClock } = await import('/src/time/clock.js'); - appClock.freeze(Date.UTC(2026, 10, 7) + scene.noonUtc * 3_600_000); - engine.camera.setView({ - destination: geo.cartesian3.fromDegrees( - scene.view.lon, - scene.view.lat, - scene.view.height, - ), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - const frames = (count) => - new Promise((resolve) => { - let left = count; - const remove = engine.frames.postRender.addEventListener(() => { - if (--left > 0) { - setTimeout(() => engine.render.request('qa-weather'), 0); - return; - } - remove(); - resolve(); - }); - engine.render.request('qa-weather'); - }); - const tryImport = async (url) => { - try { - return await import(url); - } catch { - return null; - } - }; - const viewer = engine.extensions?.cesium?.viewer ?? null; - const native = engine.extensions?.navara ?? null; - const cesium = viewer ? await import('/qa-cesium.js') : null; - const qa = { frames }; - window.__qaWeather = qa; - if (scene.kind === 'weather') { - const snapshot = { - schemaVersion: 1, - product: scene.product, - bounds: scene.bounds, - times: [time], - latest: time, - tileSize: 256, - maxLevel: 6, - tilingScheme: 'geographic', - }; - const display = await tryImport( - '/src/layers/weather/render/display.js', - ); - let renderer; - let used; - if (display) { - renderer = display.createWeatherDisplay(engine, {}); - used = 'display'; - } else if (viewer) { - const { createWeatherRendering } = - await import('/src/layers/weather/rendering.js'); - renderer = createWeatherRendering({ - viewer, - cesium, - getHost: () => ({ - collection: viewer.imageryLayers, - kind: 'globe', - }), - }); - used = 'cesium'; - } else { - const { createWeatherNavaraRenderer } = - await import('/src/layers/weather/render/navara.js'); - renderer = createWeatherNavaraRenderer(engine, native, { - getHost: () => ({ kind: 'globe', collection: engine }), - }); - used = 'navara'; - } - renderer.setAlpha(scene.alpha); - qa.show = () => - renderer.setFrame(snapshot, time, { - infrared: scene.product.startsWith('clouds') ? 100 : 'filtered', - }); - qa.state = () => renderer.getDiagnostics(); - return used; - } - if (scene.kind === 'wind') { - const display = await tryImport('/src/layers/wind/render/display.js'); - const { createWindRendering } = - await import('/src/layers/wind/rendering.js'); - const cesiumFlow = (options = {}) => - createWindRendering({ - cesium, - container: viewer.container, - getViewer: () => viewer, - getHost: () => ({ - collection: viewer.imageryLayers, - kind: 'globe', - }), - ...options, - }); - let renderer; - let used; - if (display) { - const navaraFlow = native - ? ( - (await tryImport('/src/globe/navara/windFlow.js')) ?? - (await import('/src/layers/wind/render/navaraFlow.js')) - ).createWindNavaraFlow - : null; - const createWindFlowMesh = native - ? (await import('/src/globe/navara/windFlowMesh.js')) - .createWindFlowMesh - : null; - renderer = display.createWindDisplay(engine, { - flow: viewer - ? () => cesiumFlow({ field: false }) - : (e) => navaraFlow(e, native, createWindFlowMesh), - }); - used = 'display'; - } else if (viewer) { - renderer = cesiumFlow(); - used = 'cesium'; - } else { - const { createWindNavaraRenderer } = - await import('/src/layers/wind/render/navara.js'); - renderer = createWindNavaraRenderer(engine, native); - used = 'navara'; - } - renderer.attach(); - renderer.setOptions({ overlay: 'none', paused: true }); - renderer.setField(wind); - renderer.start(); - qa.show = () => renderer.setOptions({ overlay: 'speed', paused: true }); - qa.state = () => renderer.getDiagnostics(); - return used; - } - // The Bhote Koshi before and after images, split at the middle. - const box = { - west: 85.295, - south: 28.135, - east: 85.379, - north: 28.292, - }; - const urls = [ - '/events/bhote-koshi-2026/pre.webp', - '/events/bhote-koshi-2026/post.webp', - ].map((url) => new URL(url, location.href).href); - const display = await tryImport( - '/src/layers/bhoteKoshi/render/display.js', - ); - if (display) { - const renderer = display.createBhoteKoshiDisplay(engine); - qa.show = async () => { - await renderer.loadImages(box, urls); - renderer.setSplit(0.5); - }; - return 'display'; - } - const Cesium = cesium ?? (await import('/qa-cesium.js')); - const rectangle = Cesium.Rectangle.fromDegrees( - box.west, - box.south, - box.east, - box.north, - ); - if (viewer) { - qa.show = async () => { - const providers = await Promise.all( - urls.map((url) => - Cesium.SingleTileImageryProvider.fromUrl(url, { rectangle }), - ), - ); - providers.forEach((provider, index) => { - const layer = viewer.imageryLayers.addImageryProvider(provider); - layer.splitDirection = index - ? Cesium.SplitDirection.RIGHT - : Cesium.SplitDirection.LEFT; - layer.alpha = 0.96; - }); - viewer.scene.splitPosition = 0.5; - }; - return 'cesium'; - } - const { createNepalEventDisplay } = - await import('/src/layers/bhoteKoshi/render/navara.js'); - const renderer = createNepalEventDisplay(engine, native); - qa.show = async () => { - const providers = await Promise.all( - urls.map((url) => - Cesium.SingleTileImageryProvider.fromUrl(url, { rectangle }), - ), - ); - await renderer.loadImages(box, providers); - renderer.setSplit(0.5); - }; - return 'navara'; - }, - { scene, time: TIME, wind: windSnapshot() }, - ); - // Settle: the map tiles loaded, then requests quiet for 2 s. - const settle = async () => { - for (let round = 0; round < 120; round++) { - const before = requests; - await page.evaluate(() => window.__qaWeather.frames(10)); - await new Promise((resolve) => setTimeout(resolve, 500)); - const loaded = await page.evaluate(() => - window.__godsEyeView.engine.surface.tilesLoaded(), - ); - if (loaded && requests === before && round >= 4) break; - } - await page.evaluate(() => window.__qaWeather.frames(20)); - }; - const read = () => - page.evaluate(() => { - const { engine } = window.__godsEyeView; - const canvas = engine.host.canvas; - const scratch = document.createElement('canvas'); - scratch.width = canvas.clientWidth; - scratch.height = canvas.clientHeight; - const context = scratch.getContext('2d'); - context.drawImage(canvas, 0, 0, scratch.width, scratch.height); - return scratch.toDataURL('image/png'); - }); - await settle(); - const without = await read(); - const shown = await page.evaluate(async () => { - const result = await window.__qaWeather.show(); - return result === undefined ? null : result; - }); - await settle(); - const withRaster = await read(); - const state = await page.evaluate(() => { - try { - return JSON.parse(JSON.stringify(window.__qaWeather.state?.() ?? null)); - } catch { - return null; - } - }); - await page.close(); - return { without, with: withRaster, errors, version, shown, state }; -} - -async function shoot(origin, out) { - fs.mkdirSync(out, { recursive: true }); - const browser = await puppeteer.launch({ - headless: true, - protocolTimeout: 600_000, - args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'], - }); - const engines = (option('engine') ?? 'cesium,navara').split(','); - const only = option('only')?.split(',') ?? null; - try { - for (const kind of engines) - for (const scene of SCENES) { - if (only && !only.includes(scene.id)) continue; - const shot = await shootScene(browser, origin, kind, scene); - for (const name of ['without', 'with']) - fs.writeFileSync( - path.join(out, `${kind}-${scene.id}-${name}.png`), - Buffer.from(shot[name].split(',')[1], 'base64'), - ); - console.log( - `${kind} ${scene.id}: ${shot.version}, shown ${JSON.stringify(shot.shown)}, error ${shot.state?.error ?? shot.state?.imageryError ?? null}${shot.errors.length ? ` (page errors: ${shot.errors.join('; ')})` : ''}`, - ); - } - } finally { - await browser.close(); - } -} - -const raw = async (file) => - (await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true })) - .data; - -// Where the raster changed the frame (any channel by more than 6). -function overlayMask(without, withRaster) { - const mask = new Uint8Array(without.length / 4); - for (let p = 0; p < mask.length; p++) - for (let k = 0; k < 3; k++) - if (Math.abs(without[p * 4 + k] - withRaster[p * 4 + k]) > 6) { - mask[p] = 1; - break; - } - return mask; -} - -// Per-pixel differences of two frames inside `mask` (or everywhere). -function difference(a, b, mask) { - let count = 0, - sum = 0, - over8 = 0, - over24 = 0, - max = 0; - for (let p = 0; p < a.length / 4; p++) { - if (mask && !mask[p]) continue; - let worst = 0; - for (let k = 0; k < 3; k++) - worst = Math.max(worst, Math.abs(a[p * 4 + k] - b[p * 4 + k])); - count++; - sum += worst; - if (worst > 8) over8++; - if (worst > 24) over24++; - max = Math.max(max, worst); - } - return { - pixels: count, - meanMaxChannel: count ? +(sum / count).toFixed(2) : 0, - over8: count ? +((100 * over8) / count).toFixed(2) : 0, - over24: count ? +((100 * over24) / count).toFixed(2) : 0, - max, - }; -} - -// The raster's own change of the frame: with minus without, per channel. -function contribution(without, withRaster) { - const out = new Int16Array(without.length); - for (let i = 0; i < without.length; i++) out[i] = withRaster[i] - without[i]; - return out; -} -const shift = (values) => - Uint8Array.from(values, (value) => Math.max(0, Math.min(255, value + 128))); - -async function compare(beforeDir, afterDir) { - const report = {}; - const engines = ['cesium', 'navara']; - for (const scene of SCENES) { - const row = (report[scene.id] = {}); - const loaded = {}; - for (const engine of engines) - for (const [version, dir] of [ - ['before', beforeDir], - ['after', afterDir], - ]) - for (const name of ['without', 'with']) { - const file = path.join(dir, `${engine}-${scene.id}-${name}.png`); - if (fs.existsSync(file)) - loaded[`${engine}-${version}-${name}`] = await raw(file); - } - for (const engine of engines) { - const before = loaded[`${engine}-before-with`]; - const after = loaded[`${engine}-after-with`]; - if (!before || !after) continue; - const mask = overlayMask(loaded[`${engine}-before-without`], before); - row[`${engine}: before vs after, where the raster draws`] = difference( - before, - after, - mask, - ); - // The raster's own change, before and after: the base map drawn - // differently between runs (tiles, lighting) cancels out. - row[`${engine}: the raster's change, before vs after`] = difference( - shift(contribution(loaded[`${engine}-before-without`], before)), - shift(contribution(loaded[`${engine}-after-without`], after)), - mask, - ); - row[`${engine}: base map before vs after`] = difference( - loaded[`${engine}-before-without`], - loaded[`${engine}-after-without`], - ); - } - // Navara against Cesium, where Cesium's raster draws: each engine's - // change of its own frame (the base maps are drawn differently). - for (const version of ['before', 'after']) { - const reference = loaded[`cesium-${version}-with`]; - const navara = loaded[`navara-${version}-with`]; - if (!reference || !navara) continue; - const mask = overlayMask(loaded[`cesium-${version}-without`], reference); - row[`navara vs cesium ${version}: the raster's change`] = difference( - shift(contribution(loaded[`cesium-${version}-without`], reference)), - shift(contribution(loaded[`navara-${version}-without`], navara)), - mask, - ); - } - } - return report; -} - -if (command === 'shoot' && first) { - await shoot(first, option('out') ?? 'qa-weather'); -} else if (command === 'compare' && first && second) { - const report = await compare(first, second); - const json = option('json'); - if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`); - for (const [scene, rows] of Object.entries(report)) { - console.log(scene); - for (const [label, value] of Object.entries(rows)) - console.log( - ` ${label}: ${value.pixels} px, mean ${value.meanMaxChannel}, >8 ${value.over8}%, >24 ${value.over24}%, max ${value.max}`, - ); - } -} else { - console.error( - 'Usage: node scripts/qa-weather-compare.mjs shoot [--out=DIR] [--engine=cesium,navara] [--only=scene,...]\n' + - ' node scripts/qa-weather-compare.mjs compare [--json=FILE]', - ); - process.exit(2); -} diff --git a/scripts/qa-weather-engine-switch.mjs b/scripts/qa-weather-engine-switch.mjs index b550c85b5..2218f2b4f 100644 --- a/scripts/qa-weather-engine-switch.mjs +++ b/scripts/qa-weather-engine-switch.mjs @@ -195,8 +195,7 @@ await strict.run({}, async (b) => { ); }; await p.evaluate(() => { - if (window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Keyless preview required'); + }); for (const id of [ids[1], ids[0], ids[2]]) { await p.evaluate( diff --git a/scripts/qa-wildlife-switch.mjs b/scripts/qa-wildlife-switch.mjs index 86508f37b..387b11231 100644 --- a/scripts/qa-wildlife-switch.mjs +++ b/scripts/qa-wildlife-switch.mjs @@ -62,9 +62,8 @@ await strict.run({}, async (b) => { await p.goto(base.href); await waitForEngine(p, 'cesium'); await p.evaluate(async (id) => { - const { dataManager, tileset } = window.__godsEyeView; - if (window.__GOOGLE_MAPS_API_KEY__ || tileset) - throw Error('Keyless preview required'); + const { dataManager } = window.__godsEyeView; + await dataManager.setEnabled(id, true, { origin: 'user' }); window.qaWild = dataManager.layers.get(id).module; // Every selection the layer reports, in order. diff --git a/scripts/qa-wind-engine-switch.mjs b/scripts/qa-wind-engine-switch.mjs index f9b6c7e05..5a274b042 100644 --- a/scripts/qa-wind-engine-switch.mjs +++ b/scripts/qa-wind-engine-switch.mjs @@ -71,9 +71,8 @@ await strict.run({}, async (b) => { await p.goto(base.href); await waitForEngine(p, 'navara'); await p.evaluate(async () => { - const { dataManager, tileset } = window.__godsEyeView; - if (tileset || window.__GOOGLE_MAPS_API_KEY__) - throw Error('Keyless preview required'); + const { dataManager } = window.__godsEyeView; + const mod = dataManager.layers.get('wind').module; window.qaWind = mod; mod.setParams({ overlay: 'temperature', paused: true, units: 'm/s' }); diff --git a/scripts/qa/appSessionHarness.mjs b/scripts/qa/appSessionHarness.mjs index 6b7666990..cc26aba73 100644 --- a/scripts/qa/appSessionHarness.mjs +++ b/scripts/qa/appSessionHarness.mjs @@ -4,28 +4,8 @@ // endpoint blocked, a fake microphone and Realtime transport, and readers for // the session ids, listener counts and transport counters. import puppeteer from 'puppeteer'; -import { blockPaidQaMaps } from '../qa-map-policy.mjs'; -import { localPreview, waitForEngine } from '../qa-strict-harness.mjs'; - -/** Paid or live model endpoints no switch gate may reach. */ -export const BLOCKED_URLS = Object.freeze([ - '*://tile.googleapis.com/*', - '*://*.googleapis.com/*3dtiles*', - '*://api.cesium.com/*', - '*://assets.ion.cesium.com/*', - '*://maps.googleapis.com/*', - '*/api/google/*', - '*/api/openai/*', - '*/api/realtime/*', - '*/api/gemini/*', - '*://api.openai.com/*', - '*://generativelanguage.googleapis.com/*', -]); - -/** The preview origin from argv, refusing the ports that belong to the user. */ -export function previewBase(argument) { - return localPreview(argument ?? ''); -} +import { SESSION_BLOCKED_URLS } from '../qa-map-policy.mjs'; +import { CHROME_ARGS, waitForEngine } from '../qa-strict-harness.mjs'; /** The page address: Boston on OSM, no welcome, on the given engine. */ export function sessionAddress(base, { engine = 'cesium', extra = {} } = {}) { @@ -46,8 +26,7 @@ export function launch() { return puppeteer.launch({ headless: true, args: [ - '--use-angle=metal', - '--enable-gpu', + ...CHROME_ARGS, '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', @@ -216,10 +195,9 @@ export async function openSessionPage(browser, address) { await browser .defaultBrowserContext() .overridePermissions(new URL(address).origin, ['microphone']); - await blockPaidQaMaps(page); const network = await page.createCDPSession(); await network.send('Network.enable'); - await network.send('Network.setBlockedURLs', { urls: [...BLOCKED_URLS] }); + await network.send('Network.setBlockedURLs', { urls: [...SESSION_BLOCKED_URLS] }); await page.evaluateOnNewDocument(installFakeVoice); await page.setViewport({ width: 1440, height: 900 }); const errors = []; @@ -228,11 +206,6 @@ export async function openSessionPage(browser, address) { return { page, errors }; } -/** The engine runs, its picker is idle and the loading screen is gone. */ -export function waitReady(page, kind) { - return waitForEngine(page, kind, { timeout: 90_000 }); -} - /** * Pick an engine the way the picker does, without moving focus to it, so a * focused field keeps its focus through the switch. @@ -247,7 +220,7 @@ export function requestEngine(page, kind) { export async function switchEngine(page, kind) { await requestEngine(page, kind); - await waitReady(page, kind); + await waitForEngine(page, kind, { timeout: 90_000 }); } /** Session object ids and counters (src/app/sessionDiagnostics.js). */ diff --git a/scripts/qa/navaraVisual.mjs b/scripts/qa/navaraVisual.mjs index 709d15bc5..852a0d52a 100644 --- a/scripts/qa/navaraVisual.mjs +++ b/scripts/qa/navaraVisual.mjs @@ -1,3 +1,4 @@ +import { assertKeylessPreview } from '../qa-strict-harness.mjs'; // Shared by the Navara visual QA scenarios (qa-navara-visual-styles, // qa-navara-bloom, qa-navara-sonar, qa-navara-cockpit-clouds): read the // engine canvas, compare frames, settle a still view, follow a style @@ -134,10 +135,10 @@ export const histogramShift = (a, b) => ) / 2; export async function freeze(page) { + await assertKeylessPreview(page); await page.evaluate(async () => { const a = window.__godsEyeView; - if (window.__GOOGLE_MAPS_API_KEY__ || a.tileset) - throw new Error('Keyless preview required'); + a.styleManager.orbitController.stop(); a.engine.camera.cancelFlight(); a.styleManager._setDetectionMode?.('OFF'); diff --git a/scripts/report-engine-coupling.mjs b/scripts/report-engine-coupling.mjs index 769c8bb62..5cda1e7a3 100644 --- a/scripts/report-engine-coupling.mjs +++ b/scripts/report-engine-coupling.mjs @@ -92,13 +92,12 @@ export const ENGINE_COUPLING_METRICS = Object.freeze([ }, { id: 'engineSniffCalls', - label: 'isGlobeEngine / engineFromViewer calls outside src/globe', + label: 'isGlobeEngine calls outside src/globe', measure: (files) => files .filter(({ file }) => !inGlobe(file)) .reduce( - (sum, { text }) => - sum + countMatches(text, /\b(?:isGlobeEngine|engineFromViewer)\(/), + (sum, { text }) => sum + countMatches(text, /\bisGlobeEngine\(/), 0, ), }, diff --git a/src/app/globeEngine.js b/src/app/globeEngine.js index 7edb0153a..89efea40e 100644 --- a/src/app/globeEngine.js +++ b/src/app/globeEngine.js @@ -1,10 +1,8 @@ import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; -import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js'; /** * Build the page's one globe engine on the application viewer and tie its - * teardown to the scene lifetime; it stays registered for the viewer - * (engineFromViewer) while it lives. Call after the viewer's own cleanup is + * teardown to the scene lifetime. Call after the viewer's own cleanup is * deferred, so the engine is destroyed first. Render holds taken before the * render governor is installed apply when it is (src/renderGovernor.js). * @param {Cesium.Viewer} viewer @@ -20,9 +18,7 @@ export function createSceneEngine( { tilesetRef, defer, createEngine = createCesiumEngine }, ) { const engine = createEngine(viewer, { tilesetRef }); - const unregister = registerViewerEngine(viewer, engine); defer(() => { - unregister(); engine.destroy(); }); return engine; diff --git a/src/app/globeEngine.test.mjs b/src/app/globeEngine.test.mjs index f389b7eaf..9f0d3297c 100644 --- a/src/app/globeEngine.test.mjs +++ b/src/app/globeEngine.test.mjs @@ -69,24 +69,3 @@ test('the debug handle exposes the engine beside the viewer', () => { /Object\.assign\(debug, \{\n\s+viewer: next\.viewer \?\? null,\n\s+engine: next\.engine,/, ); }); - -test('the scene engine is the one engineFromViewer returns for its viewer until teardown', async () => { - const { engineFromViewer } = - await import('../globe/cesium/engineFromViewer.js'); - const { createFakeEngine } = await import('../globe/testing/fakeEngine.js'); - const viewer = { name: 'viewer' }; - const cleanups = []; - const engine = createSceneEngine(viewer, { - tilesetRef: null, - defer: (dispose) => cleanups.push(dispose), - createEngine: () => createFakeEngine(), - }); - assert.equal(engineFromViewer(viewer), engine); - for (const cleanup of cleanups.reverse()) cleanup(); - assert.equal(engine.fake.destroyed, true); - assert.equal( - engineFromViewer(viewer), - null, - 'no longer registered, and none is built', - ); -}); diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index ed68cc3ff..7c5379992 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -11,7 +11,7 @@ import { rendererFor, } from './renderers.js'; import { APPLICATION_LAYER_DIRECTORY } from './directory.js'; -import { ENGINE_KINDS, engineCapabilities } from '../../globe/contract.js'; +import { ENGINE_KINDS, assertGlobeEngine } from '../../globe/contract.js'; import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; test('the manifest lists every directory layer, in its order', () => { @@ -25,9 +25,9 @@ test('the manifest lists every directory layer, in its order', () => { }); test('the primitives a display needs are the contract primitives every engine has', () => { - const capabilities = engineCapabilities(createFakeEngine()); - for (const name of CONTRACT_PRIMITIVES) - assert.equal(capabilities[name], true, name); + const engine = createFakeEngine(); + assertGlobeEngine(engine); + for (const name of CONTRACT_PRIMITIVES) assert.ok(engine[name], name); for (const [id, entry] of Object.entries(LAYER_MANIFEST)) for (const name of entry.needs ?? []) assert.ok(CONTRACT_PRIMITIVES.includes(name), `${id} needs ${name}`); diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 2c37f4fe0..af675782b 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -34,7 +34,7 @@ import { registerEntityContext, selectEntityContext } from './contextStore.js'; import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js'; import { ensureGeoidReady, geoidHeight } from './geoid.js'; import { pickRegistryFor } from './pickRegistry.js'; -const wireEngine = {}; +const wireEngine = createFakeEngine({ width: 1600, height: 900 }); const { registerPickOwner, unregisterPickOwner } = pickRegistryFor(wireEngine); import { applyVesselOverlayPolicy } from './vesselLabels.js'; import { layerFeedState } from './manager.js'; @@ -42,12 +42,20 @@ import { presentSelectedVessel, subscribeSelectedVessel, } from '../hudReadouts.js'; -import { vesselViewerRenderer } from '../testSupport/vesselViewerRenderer.mjs'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { createVesselsDisplay, vesselAtlasLayout } from '../layers/vessels/render/display.js'; + +function vesselDisplay(engine = createFakeEngine({ width: 1600, height: 900 })) { + const layout = vesselAtlasLayout(); + const renderer = createVesselsDisplay(engine, { + drawAtlas: async () => ({ canvas: { width: layout.width, height: layout.height }, cells: layout.cells }), + }); + renderer.fleet.attach({ show: true }); + return renderer; +} -// The layer's state over the test's viewer, drawn by the Cesium-shaped test -// renderer (its billboard collection, projection and trackedEntityChanged). const _setVesselStateForTest = (options = {}) => - setVesselStateForTest({ renderer: vesselViewerRenderer, ...options }); + setVesselStateForTest({ renderer: () => options.enabled === false ? null : vesselDisplay(options.engine), ...options }); test('open feed with vessels is healthy (null)', () => { assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null); @@ -953,16 +961,17 @@ function installWireHarness(picked, stateOverrides = {}) { globalThis.document = { getElementById: () => null, }; - // The test renderer draws in no scene; the harness viewer's - // trackedEntityChanged is its follow signal. - const followViewer = { - get trackedEntity() { - return viewer.trackedEntity; - }, - trackedEntityChanged, + Object.defineProperty(wireEngine.camera, 'followChanged', { configurable: true, value: trackedEntityChanged }); + wireEngine.camera.following = () => viewer.trackedEntity ?? null; + const pickedRecord = picked?.id && typeof picked.id === 'object' && Object.hasOwn(picked.id, 'mmsi') ? picked.id : null; + const renderer = () => { + const display = vesselDisplay(wireEngine); + if (pickedRecord) { + const handle = display.fleet.add({ id: pickedRecord, position: pickedRecord.position }); + picked = { id: handle.pickId }; + } + return display; }; - const renderer = ({ billboardCollection }) => - ({ ...vesselViewerRenderer({ viewer: followViewer, billboardCollection }), engine: wireEngine }); _setVesselStateForTest({ records: [record], selectedRecord: record, @@ -1413,7 +1422,8 @@ test('buildSelectedVesselCard: full detail card with MMSI + position time', () = test('vessel host publication preserves the shipped grid winner and separation selector', () => { const publications = []; - const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates; + const engine = createFakeEngine({ width: 1600, height: 900 }); + engine.projector.worldToWindow = (position) => position.screen; const makeCandidate = (mmsi, name, x, y, speed = 1) => makeRecord({ mmsi, name, @@ -1423,7 +1433,6 @@ test('vessel host publication preserves the shipped grid winner and separation s const low = makeCandidate('100', 'VESSEL', 20, 20, 1); const winner = makeCandidate('200', 'NAMED WINNER', 40, 30, 16); const separated = makeCandidate('300', 'SEPARATED', 400, 300, 3); - Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, position) => position.screen; _setVesselOverlayHostForTest({ setEntries(sourceId, entries, options) { publications.push({ sourceId, entries, options }); }, setVisible() {}, @@ -1432,6 +1441,7 @@ test('vessel host publication preserves the shipped grid winner and separation s _setVesselStateForTest({ viewer: { scene: { canvas: { clientWidth: 1600, clientHeight: 900 } } }, records: [low, winner, separated], + engine, }); try { _updateVesselCardsForTest([low, winner, separated]); @@ -1444,7 +1454,7 @@ test('vessel host publication preserves the shipped grid winner and separation s assert.equal(publications[0].options.cohortLimit, 112); assert.equal(publications[0].options.collisionCapacity, 112); } finally { - Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection; + engine.destroy(); _setVesselStateForTest({ enabled: false }); _setVesselOverlayHostForTest(); } diff --git a/src/data/cockpitDetectionSubjects.test.mjs b/src/data/cockpitDetectionSubjects.test.mjs index 8f431978d..db8970a22 100644 --- a/src/data/cockpitDetectionSubjects.test.mjs +++ b/src/data/cockpitDetectionSubjects.test.mjs @@ -11,7 +11,7 @@ import militaryFlightsLayer, { _setCockpitDetectionSubjectForTest as setMilitaryCockpitSubject, _setTrackedMilitaryRefreshStateForTest, } from './militaryFlights.js'; -import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; +import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs'; const SUBJECT = 'abc123'; const NEXT_SUBJECT = 'def456'; @@ -26,7 +26,9 @@ const LAYERS = [ setSubject: setFlightsCockpitSubject, seed(icao24) { _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24, entity: null, billboard: candidateBillboard(icao24), @@ -48,7 +50,9 @@ const LAYERS = [ setSubject: setMilitaryCockpitSubject, seed(icao24) { _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24, entity: null, billboard: candidateBillboard(icao24), diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 860ffcf4a..6624cf8f7 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -28,8 +28,9 @@ import { destroyTrackedReadout, initTrackedReadout, } from './trackedReadout.js'; -import { trackingEngine } from '../testSupport/viewerEngine.mjs'; -import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { createAircraftDisplay, aircraftAtlasLayout } from '../layers/aircraft/render/display.js'; +import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs'; test('share-Follow absence requires an accepted OpenSky snapshot', async () => { _setFlightTrackingRefreshOutcomeForTest({ status: 'source-unavailable' }); @@ -185,7 +186,9 @@ test('flights first update forwards caller cancellation into the feed request', test('nonempty OpenSky payload with zero usable rows cannot prove share target absence', async () => { _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: 'abc123', entity: { gevLabelModel: { title: 'WARM', details: [] } }, billboard: { show: false }, @@ -241,7 +244,9 @@ test('a newly seen aircraft appears at its delayed position, not ahead of it', a }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: 'seed00', entity: null, billboard: { @@ -330,7 +335,9 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA const billboardCollection = { show: false, remove() {} }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24, entity, billboard, @@ -411,45 +418,17 @@ test('real civil track path creates no native label and publishes every cached h const icao24 = 'civ001'; const position = Cesium.Cartesian3.fromDegrees(-97.6699, 30.1945, 10_668); const now = Cesium.JulianDate.now(); - const entities = new Cesium.EntityCollection(); - const trackedEntityChanged = new Cesium.Event(); - let trackedEntity; - let cancelledFlights = 0; - let appliedFrames = 0; - const viewer = { - entities, - trackedEntityChanged, - clock: { currentTime: now }, - camera: { - cancelFlight() { - cancelledFlights += 1; - }, - position: new Cesium.Cartesian3(0, -10_000, 7_000), - positionWC: new Cesium.Cartesian3(0, -10_000, 7_000), - direction: Cesium.Cartesian3.UNIT_Y, - transform: Cesium.Matrix4.IDENTITY, - viewMatrix: Cesium.Matrix4.IDENTITY, - frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY }, - lookAtTransform() { - appliedFrames += 1; - }, - }, - scene: { - canvas: { clientWidth: 1600, clientHeight: 900 }, - frameState: { frameNumber: 41, mode: Cesium.SceneMode.SCENE3D }, - preUpdate: new Cesium.Event(), - screenSpaceCameraController: null, - }, - isDestroyed: () => false, - }; - Object.defineProperty(viewer, 'trackedEntity', { - get: () => trackedEntity, - set(value) { - trackedEntity = value; - trackedEntityChanged.raiseEvent(value); - }, + const engine = createFakeEngine(); + let followCalls = 0; + const follow = engine.camera.follow; + engine.camera.follow = (options) => { followCalls++; return follow(options); }; + const layout = aircraftAtlasLayout(); + const renderer = createAircraftDisplay(engine, { + layerId: 'flights', + drawAtlas: async () => ({ canvas: { width: layout.width, height: layout.height }, cells: layout.cells }), }); - viewer.scene.camera = viewer.camera; + renderer.fleet.attach(); + renderer.fleet.setVisible(true); const billboard = { position, rotation: 0.3, @@ -478,17 +457,15 @@ test('real civil track path creates no native label and publishes every cached h }, ); globalThis.fetch = async () => ({ ok: false }); - const engine = trackingEngine(viewer); const readoutServices = createSceneServices(engine, { trackedOverlay: host }); try { initTrackedReadout(engine); _setTrackedFlightRefreshStateForTest({ - renderer: (options) => aircraftViewerRenderer({ ...options, engine }), + renderer: () => renderer, icao24, entity: null, billboard, billboardCollection: { show: true, remove() {} }, - viewer, tracked: false, history: [ { @@ -525,27 +502,19 @@ test('real civil track path creates no native label and publishes every cached h flightsLayer.trackById(icao24, { origin: 'programmatic' }), true, ); - const entity = viewer.trackedEntity; - assert.ok( - entity instanceof Cesium.Entity, - 'trackById must create the real Cesium entity', - ); - assert.equal(entity.label, undefined); - assert.ok( - entities.values.every((candidate) => candidate.label === undefined), - ); + const entity = renderer.follow.current(); + assert.ok(entity, 'tracking creates a display handle'); assert.deepEqual(entity.gevLabelModel, { title: 'N12345 · FL350 · 486 kts', details: ['TEST AIR · A320', 'AUS → LAX'], accent: '#39d0ff', }); - viewer.scene.preUpdate.raiseEvent(); - const initialAppliedFrames = appliedFrames; - const initialCancelledFlights = cancelledFlights; + engine.fake.frame(); + const initialFollowCalls = followCalls; assert.equal(flightsLayer.trackById(icao24, { origin: 'user' }), true); assert.equal( - cancelledFlights, - initialCancelledFlights, + followCalls, + initialFollowCalls, 'ordinary repeated tracking stays camera-idempotent', ); assert.equal( @@ -565,21 +534,16 @@ test('real civil track path creates no native label and publishes every cached h 'same-target refocus forwards explicit authority', ); assert.equal(flightsLayer.refocusTrackedById(icao24), true); - viewer.scene.preUpdate.raiseEvent(); + engine.fake.frame(); assert.equal( - viewer.trackedEntity, + renderer.follow.current(), entity, 'refocus must retain the exact tracked entity', ); assert.equal( - appliedFrames, - initialAppliedFrames + 1, - 'repeated refocus keeps one camera-frame owner', - ); - assert.equal( - cancelledFlights, - initialCancelledFlights + 2, - 'only explicit refocus requests cancel camera flights', + followCalls, + initialFollowCalls + 2, + 'only explicit refocus requests restart following', ); const publication = publications.at(-1); assert.equal(publication.sourceId, 'tracked'); @@ -589,7 +553,7 @@ test('real civil track path creates no native label and publishes every cached h assert.equal(entry.title, entity.gevLabelModel.title); assert.deepEqual(entry.details, entity.gevLabelModel.details); - const display = entity.position.getValue(now); + const display = entity.spec.position(); assert.ok(display, 'tracked position callback must seed its frame cache'); assert.equal( entry.position(), @@ -710,9 +674,11 @@ test('real civil track path creates no native label and publishes every cached h assert.equal(flightsLayer.getParams().selectedFlightsTrackingId, 'late005'); } finally { flightsLayer.stopTracking(); - viewer.scene.preUpdate.raiseEvent(); + engine.fake.frame(); destroyTrackedReadout(engine); readoutServices.destroy(); + renderer.destroy(); + engine.destroy(); globalThis.fetch = realFetch; globalThis.window = realWindow; } @@ -743,7 +709,9 @@ function seedLabelContact({ meta: metaOverrides = {}, }) { _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: LABEL_ICAO, entity: null, billboard: { @@ -952,7 +920,9 @@ test('civil tracked readout: a callsign-less enriched contact reads as its regis const entity = { gevLabelModel: { title: 'OLD', details: [] } }; seedLabelContact({ callsign: ' ', registration: ' N123AB ', tracked: true }); _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: LABEL_ICAO, entity, billboard: { @@ -1091,7 +1061,9 @@ test('a newly seen taxiing aircraft starts on the floor under its delayed positi }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: 'seed00', entity: null, billboard: { diff --git a/src/data/iconOrientation.js b/src/data/iconOrientation.js index ba1129daa..cc608669c 100644 --- a/src/data/iconOrientation.js +++ b/src/data/iconOrientation.js @@ -21,10 +21,7 @@ * keeps rotation continuous through tracked orbits; field evidence, not an * unverified math swap, decides whether exact projection becomes preferred. */ -import { - worldToWindowCoordinates, - horizonOccluder, -} from '../globe/cesium/sceneProjection.js'; +import { worldToWindowCoordinates } from '../globe/cesium/sceneProjection.js'; import * as geo from '../geo/index.js'; // A window position through the engine projector the scene carries (the @@ -148,7 +145,7 @@ const _wgs84OneOverRadii = geo.ellipsoid.WGS84.oneOverRadii; * ABOVE the ellipsoid the horizon is the tangent silhouette and the test is a * genuine ray/ellipsoid hit test: 1 means the view ray through the point misses * the planet entirely. Here the function is the exact complement of - * {@link horizonOccluder} — the occluder asks whether the planet is in FRONT of + * the horizon visibility test — the occluder asks whether the planet is in FRONT of * a point (cull it), this asks whether it is BEHIND (what a screen-space label * anchored there is read against). For any point the occluder keeps, a ray that * hits the ellipsoid must hit it beyond the point, so the hit/miss test alone @@ -252,7 +249,6 @@ export function skyBackdropFactor( * @param {Cesium.Camera} camera - The scene camera. * @returns {Cesium.EllipsoidalOccluder} */ -export { horizonOccluder }; /** * Cheap camera pose signature for "did the camera move" gating of rotation diff --git a/src/data/iconOrientation.test.mjs b/src/data/iconOrientation.test.mjs index ea707fafa..1f2afcb09 100644 --- a/src/data/iconOrientation.test.mjs +++ b/src/data/iconOrientation.test.mjs @@ -3,7 +3,6 @@ import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { HORIZON_FEATHER_RAD, - horizonOccluder, screenProjectedRotation, skyBackdropFactor, stabilizeScreenRotation, @@ -483,7 +482,7 @@ test('below the surface the backdrop test agrees with the occluder beside it', ( // halves use that plane. The old fail-closed broke exactly this: the occluder // kept a contact against sky and the backdrop test called it ground. const camera = Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, -18); - const occluder = horizonOccluder({ positionWC: camera }); + const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, camera); const enu = Cesium.Transforms.eastNorthUpToFixedFrame(camera); const offsetBy = (up) => Cesium.Cartesian3.add( diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 54dbdabe6..8fc5531af 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -45,7 +45,7 @@ import { presentAwarenessDistance, } from '../layers/awareness/panel.js'; import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js'; -import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; +import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs'; import { registerLayerRenderers } from '../app/layers/renderers.js'; // The layer's view of a stub viewer (render/display.js's interface), drawn @@ -979,7 +979,9 @@ test('Contacts activation adopts the production military tracked-subject descrip try { _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: id, entity: null, billboard: { position, show: true }, @@ -1062,7 +1064,9 @@ test('Contacts activation adopts the production military tracked-subject descrip } finally { restores.reverse().forEach((restore) => restore()); _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: id, entity: null, billboard: { position, show: true }, @@ -1090,7 +1094,9 @@ test('production military tracked-subject label falls back to registration befor try { _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: id, entity: null, billboard: { position, show: true }, @@ -1144,7 +1150,9 @@ test('production military tracked-subject label falls back to registration befor } finally { restores.reverse().forEach((restore) => restore()); _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: id, entity: null, billboard: { position, show: true }, @@ -1191,7 +1199,9 @@ test('a cached Context subject re-reads its label when enrichment lands after se try { _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: id, entity: null, billboard: { position, color: Cesium.Color.WHITE, show: true }, @@ -1280,7 +1290,9 @@ test('a cached Context subject re-reads its label when enrichment lands after se } finally { restores.reverse().forEach((restore) => restore()); _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: id, entity: null, billboard: { position, show: true }, @@ -1303,7 +1315,9 @@ test('Contacts activation adopts the production civilian tracked-subject descrip try { _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: id, entity: null, billboard: { position, show: true }, @@ -1393,7 +1407,9 @@ test('Contacts activation adopts the production civilian tracked-subject descrip } finally { restores.reverse().forEach((restore) => restore()); _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: id, entity: null, billboard: { position, show: true }, diff --git a/src/data/militaryFlights.edgeCases.test.mjs b/src/data/militaryFlights.edgeCases.test.mjs index 8bce5888e..63a81937c 100644 --- a/src/data/militaryFlights.edgeCases.test.mjs +++ b/src/data/militaryFlights.edgeCases.test.mjs @@ -16,7 +16,7 @@ import { registerMilitaryIcaos, setMilitaryLayerActive, } from './militaryRegistry.js'; -import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; +import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs'; const SHARED_HEX = 'ae5e01'; const CIVIL_HEX = 'a0c1v1'; @@ -85,7 +85,9 @@ test('edge:military: an ICAO in both feeds draws one target and keeps the milita const civilBillboards = collection(); _armFlightTrackingRestoreForTest(null); _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: 'seedm0', entity: null, billboard: { show: false, position: Cesium.Cartesian3.ZERO }, @@ -95,7 +97,9 @@ test('edge:military: an ICAO in both feeds draws one target and keeps the milita tracked: false, }); _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: 'seedf0', entity: null, billboard: { show: false, position: Cesium.Cartesian3.ZERO }, @@ -173,8 +177,10 @@ test('edge:military: losing the tracked contact from the owning feed releases th entities: { add: (value) => value, remove: () => true }, trackedEntity: entity, }; + const renderer = recordingAircraftRenderer(null, { fleet: { shown: true } }); + renderer.follow.start(entity); _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: () => renderer, icao24: SHARED_HEX, entity, billboard: { @@ -214,7 +220,7 @@ test('edge:military: losing the tracked contact from the owning feed releases th } assert.equal(militaryFlightsLayer.hasContact(SHARED_HEX), false); assert.equal( - viewer.trackedEntity, + renderer.follow.current(), undefined, 'the follow camera is released', ); diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 87c9031a7..3adcff457 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -17,8 +17,9 @@ import { destroyTrackedReadout, initTrackedReadout, } from './trackedReadout.js'; -import { trackingEngine } from '../testSupport/viewerEngine.mjs'; -import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { createAircraftDisplay, aircraftAtlasLayout } from '../layers/aircraft/render/display.js'; +import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs'; test('share-Follow absence requires an accepted adsb.lol snapshot', async () => { _setMilitaryTrackingRefreshOutcomeForTest({ status: 'source-unavailable' }); @@ -148,7 +149,9 @@ test('military first update forwards caller cancellation into the feed request', test('nonempty adsb.lol payload with zero usable rows cannot prove share target absence', async () => { _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: 'ae1234', entity: { gevLabelModel: { title: 'WARM', details: [] } }, billboard: { show: false }, @@ -195,7 +198,9 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p const billboardCollection = { show: false, remove() {} }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24, entity, billboard, @@ -267,7 +272,9 @@ test('cached military snapshots retain their observation time and expose stale f const history = []; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: 'ae01ce', entity: { gevLabelModel: { title: 'RCH451', details: [] } }, billboard: { @@ -339,45 +346,17 @@ test('real military track path creates no native label and publishes every cache const icao24 = 'ae01ce'; const position = Cesium.Cartesian3.fromDegrees(-97.03, 31.05, 8_534.4); const now = Cesium.JulianDate.now(); - const entities = new Cesium.EntityCollection(); - const trackedEntityChanged = new Cesium.Event(); - let trackedEntity; - let cancelledFlights = 0; - let appliedFrames = 0; - const viewer = { - entities, - trackedEntityChanged, - clock: { currentTime: now }, - camera: { - cancelFlight() { - cancelledFlights += 1; - }, - position: new Cesium.Cartesian3(0, -10_000, 7_000), - positionWC: new Cesium.Cartesian3(0, -10_000, 7_000), - direction: Cesium.Cartesian3.UNIT_Y, - transform: Cesium.Matrix4.IDENTITY, - viewMatrix: Cesium.Matrix4.IDENTITY, - frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY }, - lookAtTransform() { - appliedFrames += 1; - }, - }, - scene: { - canvas: { clientWidth: 1600, clientHeight: 900 }, - frameState: { frameNumber: 61, mode: Cesium.SceneMode.SCENE3D }, - preUpdate: new Cesium.Event(), - screenSpaceCameraController: null, - }, - isDestroyed: () => false, - }; - viewer.scene.camera = viewer.camera; - Object.defineProperty(viewer, 'trackedEntity', { - get: () => trackedEntity, - set(value) { - trackedEntity = value; - trackedEntityChanged.raiseEvent(value); - }, + const engine = createFakeEngine(); + let followCalls = 0; + const follow = engine.camera.follow; + engine.camera.follow = (options) => { followCalls++; return follow(options); }; + const layout = aircraftAtlasLayout(); + const renderer = createAircraftDisplay(engine, { + layerId: 'military', + drawAtlas: async () => ({ canvas: { width: layout.width, height: layout.height }, cells: layout.cells }), }); + renderer.fleet.attach(); + renderer.fleet.setVisible(true); const billboard = { position, rotation: 0.4, @@ -406,17 +385,15 @@ test('real military track path creates no native label and publishes every cache }, ); globalThis.fetch = async () => ({ ok: false }); - const engine = trackingEngine(viewer); const readoutServices = createSceneServices(engine, { trackedOverlay: host }); try { initTrackedReadout(engine); _setTrackedMilitaryRefreshStateForTest({ - renderer: (options) => aircraftViewerRenderer({ ...options, engine }), + renderer: () => renderer, icao24, entity: null, billboard, billboardCollection: { show: true, remove() {} }, - viewer, tracked: false, history: [ { @@ -440,30 +417,22 @@ test('real military track path creates no native label and publishes every cache militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true, ); - const entity = viewer.trackedEntity; - assert.ok( - entity instanceof Cesium.Entity, - 'trackById must create the real Cesium entity', - ); - assert.equal(entity.label, undefined); - assert.ok( - entities.values.every((candidate) => candidate.label === undefined), - ); + const entity = renderer.follow.current(); + assert.ok(entity, 'tracking creates a display handle'); assert.deepEqual(entity.gevLabelModel, { title: 'RCH451', details: ['C17 · 05-8152', 'United States Air Force · 28000 ft · 450 kt'], accent: '#ffd166', }); - viewer.scene.preUpdate.raiseEvent(); - const initialAppliedFrames = appliedFrames; - const initialCancelledFlights = cancelledFlights; + engine.fake.frame(); + const initialFollowCalls = followCalls; assert.equal( militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true, ); assert.equal( - cancelledFlights, - initialCancelledFlights, + followCalls, + initialFollowCalls, 'ordinary repeated tracking stays camera-idempotent', ); assert.equal( @@ -486,21 +455,16 @@ test('real military track path creates no native label and publishes every cache 'same-target refocus forwards explicit authority', ); assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true); - viewer.scene.preUpdate.raiseEvent(); + engine.fake.frame(); assert.equal( - viewer.trackedEntity, + renderer.follow.current(), entity, 'refocus must retain the exact tracked entity', ); assert.equal( - appliedFrames, - initialAppliedFrames + 1, - 'repeated refocus keeps one camera-frame owner', - ); - assert.equal( - cancelledFlights, - initialCancelledFlights + 2, - 'only explicit refocus requests cancel camera flights', + followCalls, + initialFollowCalls + 2, + 'only explicit refocus requests restart following', ); const publication = publications.at(-1); assert.equal(publication.sourceId, 'tracked'); @@ -510,7 +474,7 @@ test('real military track path creates no native label and publishes every cache assert.equal(entry.title, entity.gevLabelModel.title); assert.deepEqual(entry.details, entity.gevLabelModel.details); - const display = entity.position.getValue(now); + const display = entity.spec.position(); assert.ok(display, 'tracked position callback must seed its frame cache'); assert.equal( entry.position(), @@ -649,9 +613,11 @@ test('real military track path creates no native label and publishes every cache ); } finally { militaryFlightsLayer.stopTracking(); - viewer.scene.preUpdate.raiseEvent(); + engine.fake.frame(); destroyTrackedReadout(engine); readoutServices.destroy(); + renderer.destroy(); + engine.destroy(); globalThis.fetch = realFetch; globalThis.window = realWindow; } @@ -851,7 +817,9 @@ test('the military tracked descriptor publishes the reported vertical rate', () // every military contact as level flight. const seedRate = (verticalRateMps) => _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: 'ae7f01', entity: { gevLabelModel: { title: 'TEST', details: [] } }, billboard: { diff --git a/src/data/nearbyModelHandoff.test.mjs b/src/data/nearbyModelHandoff.test.mjs index 20567291b..516b4ba8b 100644 --- a/src/data/nearbyModelHandoff.test.mjs +++ b/src/data/nearbyModelHandoff.test.mjs @@ -12,7 +12,7 @@ import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import flightsLayer, { _setTrackedFlightRefreshStateForTest } from './flights.js'; import militaryFlightsLayer, { _setTrackedMilitaryRefreshStateForTest } from './militaryFlights.js'; -import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; +import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs'; const ICAO = 'abc123'; const OTHER = 'def456'; @@ -42,7 +42,9 @@ const LAYERS = [ visualCenterM: Cesium.Cartesian3.ZERO, seed({ billboardShow, models = [] }) { _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: ICAO, entity: null, billboard: contactBillboard(billboardShow), @@ -65,7 +67,9 @@ const LAYERS = [ visualCenterM: Cesium.Cartesian3.ZERO, seed({ billboardShow, models = [] }) { _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24: ICAO, entity: null, billboard: contactBillboard(billboardShow), diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs index 55e97f785..25528ba6b 100644 --- a/src/data/tr3bRegistry.test.mjs +++ b/src/data/tr3bRegistry.test.mjs @@ -33,7 +33,7 @@ import militaryFlightsLayer, { import { findCompatibleHistoryIndex } from './militaryAwareness.js'; import { createGevActionRunner } from '../voice/gevActions.js'; import { ANALYST_LAYERS, createAnalystEngine } from './analystEngine.js'; -import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; +import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs'; /** Strip block and line comments so source pins scan CODE, not prose. */ function stripComments(source) { @@ -162,7 +162,9 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24, entity, billboard, @@ -297,7 +299,9 @@ test('conversions are session-scoped and no lifecycle path clears them', async ( setTr3b(icao24, true); const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24, entity: null, billboard: { @@ -462,7 +466,9 @@ test('cockpit class filter matches a converted contact end to end', async () => // 2) Real getNearby record for a real (converted) contact in the layer. const seed = () => _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24, entity: null, billboard: { @@ -509,7 +515,9 @@ test('military records and detection cards agree with the conversion', () => { const icao24 = 'ae01ce'; const center = Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 200); const seed = () => _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, { + fleet: { ...billboardCollection, shown: billboardCollection?.show !== false }, + }), icao24, entity: null, billboard: { diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 1296a4e05..249526a1b 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -26,7 +26,6 @@ import { FLEET_MODEL_ALT_CEIL_M, TRACKED_MODEL_EXIT_RATIO, } from './trackedModelRegime.js'; -import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js'; import * as Cesium from 'cesium'; import flightsLayer, { _setTrackedFlightRefreshStateForTest, @@ -46,7 +45,7 @@ import militaryFlightsLayer, { } from './militaryFlights.js'; import { clearTr3bRegistry, setTr3b } from './tr3bRegistry.js'; import { reportMeshFloorCell, _clearMeshFloorCellsForTest } from './groundFloor.js'; -import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; +import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs'; // Sample altitudes are expressed RELATIVE to the band rather than as absolute // offsets. The thresholds have already been retuned once (1_000_000 → 150_000 @@ -173,9 +172,7 @@ function viewerAtHeight(height, { tilesLoaded = true, sampleHeight = () => undef trackedEntity: undefined, entities: { remove() {} }, }; - // The ground snap samples through the viewer's engine (the test - // renderer's surfaceOwner, engineFromViewer): the engine surface over this stub's tileset and scene.sampleHeight. - registerViewerEngine(viewer, { + viewer.engine = { isDestroyed: () => false, surface: { tilesLoaded: () => viewer.tileset.tilesLoaded === true, @@ -184,7 +181,7 @@ function viewerAtHeight(height, { tilesLoaded = true, sampleHeight = () => undef return Number.isFinite(h) ? { height: h, source: 'tiles' } : undefined; }, }, - }); + }; return viewer; } @@ -207,6 +204,28 @@ function recordingModelCollection() { return { added, add(m) { added.push(m); }, remove() {}, isDestroyed() { return false; } }; } +let loadModel = () => Promise.reject(new Error('No model fixture')); +function rendererFor(viewer, collection) { + return recordingAircraftRenderer(viewer.engine, { + view: { camera: viewer.camera, scene: viewer.scene }, + fleet: { shown: true }, + models: { + live: () => Boolean(collection) && !collection.isDestroyed(), + loadFleet: (spec) => loadModel(spec), + loadTracked: (spec) => loadModel(spec), + preload: (url) => loadModel({ url }), + add: (model) => collection.add(model), + remove: (model) => collection?.remove(model), + destroy: (model) => model.destroy(), + setPose(model, position, headingDeg, offsetDeg = 180) { + return Cesium.Transforms.headingPitchRollToFixedFrame(position, + new Cesium.HeadingPitchRoll(Cesium.Math.toRadians((headingDeg || 0) + offsetDeg), 0, 0), + Cesium.Ellipsoid.WGS84, undefined, model.modelMatrix); + }, + }, + }); +} + const LAYERS = [ { name: 'flights', @@ -220,7 +239,7 @@ const LAYERS = [ seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) { const billboard = contactBillboard(); _setTrackedFlightRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: () => rendererFor(viewer, modelCollection), icao24, entity: null, billboard, @@ -246,7 +265,7 @@ const LAYERS = [ seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) { const billboard = contactBillboard(); _setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: () => rendererFor(viewer, modelCollection), icao24, entity: null, billboard, @@ -268,7 +287,7 @@ for (const fixture of LAYERS) { fixture.layer.stopTracking(); fixture.layer.setParams({ models3d: true }); - const realFrom = Cesium.Model.fromGltfAsync; + const realFrom = loadModel; const viewer = viewerAtHeight(30_000, { tilesLoaded: false }); const position = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668); const loadedCollection = recordingModelCollection(); @@ -282,7 +301,7 @@ for (const fixture of LAYERS) { try { const loadingIcao = fixture.name === 'flights' ? 'a0f201' : 'b0f201'; - Cesium.Model.fromGltfAsync = () => Promise.resolve(loadingModel); + loadModel = () => Promise.resolve(loadingModel); const billboard = fixture.seed({ viewer, tracked: false, @@ -316,7 +335,7 @@ for (const fixture of LAYERS) { const failedIcao = fixture.name === 'flights' ? 'a0f202' : 'b0f202'; const failedCollection = recordingModelCollection(); - Cesium.Model.fromGltfAsync = () => Promise.reject(new Error('simulated fleet GLB failure')); + loadModel = () => Promise.reject(new Error('simulated fleet GLB failure')); const failedBillboard = fixture.seed({ viewer, tracked: false, @@ -333,7 +352,7 @@ for (const fixture of LAYERS) { .some((object) => object.icao24 === failedIcao), 'fleet load rejection leaves the contact in the billboard proximity cohort'); } finally { - Cesium.Model.fromGltfAsync = realFrom; + loadModel = realFrom; fixture.layer.setParams({ models3d: false }); fixture.layer.stopTracking(); } @@ -593,7 +612,7 @@ for (const fixture of LAYERS) { fixture.layer.stopTracking(); fixture.layer.setParams({ models3d: false }); - const realFrom = Cesium.Model.fromGltfAsync; + const realFrom = loadModel; const realNow = Date.now; let now = 3_000_000; Date.now = () => now; @@ -611,7 +630,7 @@ for (const fixture of LAYERS) { scale: 1, destroy() {}, }; - Cesium.Model.fromGltfAsync = () => Promise.resolve(model); + loadModel = () => Promise.resolve(model); try { const billboard = fixture.seed({ @@ -704,7 +723,7 @@ for (const fixture of LAYERS) { assert.equal(fixture.trackedBillboardColor().alpha, 1, 'there is no invisible-owner gap while the replacement snap is unavailable'); } finally { - Cesium.Model.fromGltfAsync = realFrom; + loadModel = realFrom; Date.now = realNow; fixture.layer.stopTracking(); } @@ -851,14 +870,14 @@ for (const fixture of LAYERS) { clearTr3bRegistry(); fixture.layer.setParams({ models3d: false }); - const realFrom = Cesium.Model.fromGltfAsync; + const realFrom = loadModel; const realNow = Date.now; let now = 1_000_000; const clock = { advance(ms) { now += ms; } }; Date.now = () => now; let attempts = 0; const requested = []; - Cesium.Model.fromGltfAsync = ({ url }) => { + loadModel = ({ url }) => { attempts++; requested.push(url); return Promise.reject(new Error('simulated missing GLB')); }; @@ -901,7 +920,7 @@ for (const fixture of LAYERS) { await driveFrames(fixture, 3, clock); assert.ok(attempts > settled, 'selecting another contact restores the retry budget'); } finally { - Cesium.Model.fromGltfAsync = realFrom; + loadModel = realFrom; Date.now = realNow; console.warn = realWarn; fixture.layer.stopTracking(); @@ -912,14 +931,14 @@ for (const fixture of LAYERS) { clearTr3bRegistry(); fixture.layer.setParams({ models3d: false }); - const realFrom = Cesium.Model.fromGltfAsync; + const realFrom = loadModel; const realNow = Date.now; const realWarn = console.warn; let now = 2_000_000; const clock = { advance(ms) { now += ms; } }; Date.now = () => now; let attempts = 0; - Cesium.Model.fromGltfAsync = () => { attempts++; return Promise.reject(new Error('simulated missing GLB')); }; + loadModel = () => { attempts++; return Promise.reject(new Error('simulated missing GLB')); }; console.warn = () => {}; try { @@ -938,7 +957,7 @@ for (const fixture of LAYERS) { assert.ok(attempts > latched, 're-tracking the same contact is a new selection and gets its retries back'); } finally { - Cesium.Model.fromGltfAsync = realFrom; + loadModel = realFrom; Date.now = realNow; console.warn = realWarn; fixture.layer.stopTracking(); diff --git a/src/data/trailRenderer.js b/src/data/trailRenderer.js deleted file mode 100644 index 9e6efe306..000000000 --- a/src/data/trailRenderer.js +++ /dev/null @@ -1 +0,0 @@ -export { createTrail } from '../globe/cesium/trail.js'; diff --git a/src/data/trailRenderer.test.mjs b/src/data/trailRenderer.test.mjs deleted file mode 100644 index 83ac45cd0..000000000 --- a/src/data/trailRenderer.test.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; -import { createTrail } from './trailRenderer.js'; - -test('trail visibility can change without discarding its accumulated geometry', () => { - const added = []; - const removed = []; - const viewer = { - isDestroyed: () => false, - entities: { - add(definition) { - added.push(definition); - return definition; - }, - remove(entity) { - removed.push(entity); - }, - }, - }; - const trail = createTrail(viewer, { color: '#ffffff' }); - const positions = [ - new Cesium.Cartesian3(1, 2, 3), - new Cesium.Cartesian3(4, 5, 6), - ]; - - trail.setVisible(false); - trail.setPositions(positions); - assert.equal(added.length, 1); - assert.equal(added[0].show, false); - assert.deepEqual(added[0].polyline.positions.getValue(), positions); - - trail.setVisible(true); - assert.equal(added[0].show, true); - assert.deepEqual(added[0].polyline.positions.getValue(), positions); - - trail.destroy(); - assert.deepEqual(removed, [added[0]]); -}); diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index 53edf00cb..1cc3c4fcd 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -56,11 +56,6 @@ test('the engine draws the visual styles, bloom and contact sonar, not the cloud bloom: true, cloudCover: false, contactSonar: true, - movers: true, - lines: true, - ground: true, - imagery: true, - meshes: true, }); assert.equal(engine.contactSonarDiagnostics().contacts, 0); engine.destroy(); diff --git a/src/globe/cesium/engineFromViewer.js b/src/globe/cesium/engineFromViewer.js deleted file mode 100644 index f9f6fa63f..000000000 --- a/src/globe/cesium/engineFromViewer.js +++ /dev/null @@ -1,42 +0,0 @@ -// The bridge for shell callers that hold only a Cesium viewer: the engine -// registered for that viewer. The app registers -// its scene engine (src/app/globeEngine.js) and unregisters it before -// destroying it. Nothing is ever built here, so no engine can outlive its -// viewer without a destroy path: an unregistered or torn-down viewer has none. - -import { isGlobeEngine } from '../contract.js'; - -const engines = new WeakMap(); - -/** - * Make `engine` the one returned for `viewer`. - * @returns {() => void} Unregister (only while it is still the registered one). - */ -export function registerViewerEngine(viewer, engine) { - // The viewer's scene and Cesium camera name the same engine, so callers - // that hold only viewer.scene (surface samplers) or viewer.camera (camera - // sensitivity claims) reach the engine. - const keys = [ - viewer, - viewer?.scene, - viewer?.camera, - viewer?.scene?.camera, - ].filter((key, index, all) => key != null && all.indexOf(key) === index); - for (const key of keys) engines.set(key, engine); - return () => { - for (const key of keys) - if (engines.get(key) === engine) engines.delete(key); - }; -} - -/** - * @param {object | null | undefined} viewer A Cesium viewer, its scene or - * camera, or an engine (returned as is). - * @returns {import('../contract.js').GlobeEngine | null} The registered - * engine, or null. - */ -export function engineFromViewer(viewer) { - if (viewer == null) return null; - if (isGlobeEngine(viewer)) return viewer; - return engines.get(viewer) ?? null; -} diff --git a/src/globe/cesium/engineFromViewer.test.mjs b/src/globe/cesium/engineFromViewer.test.mjs deleted file mode 100644 index ffcd76ed3..000000000 --- a/src/globe/cesium/engineFromViewer.test.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { isGlobeEngine } from '../contract.js'; -import { createFakeEngine } from '../testing/fakeEngine.js'; -import { engineFromViewer, registerViewerEngine } from './engineFromViewer.js'; -import { createFakeViewer } from './testing/fakeViewer.mjs'; - -test('a viewer without a registered engine has none: nothing is built', () => { - const viewer = createFakeViewer(); - assert.equal(engineFromViewer(viewer), null); - assert.deepEqual(viewer.calls, []); -}); - -test('the engine registered for a viewer is returned until it is unregistered', () => { - const viewer = createFakeViewer(); - const engine = createFakeEngine(); - const unregister = registerViewerEngine(viewer, engine); - assert.equal(engineFromViewer(viewer), engine); - unregister(); - assert.equal(engineFromViewer(viewer), null, 'a torn-down viewer has none'); - const next = createFakeEngine(); - registerViewerEngine(viewer, next); - unregister(); - assert.equal( - engineFromViewer(viewer), - next, - 'a stale unregister keeps the new one', - ); -}); - -test('an engine passes through, and no viewer is no engine', () => { - const engine = createFakeEngine(); - assert.equal(engineFromViewer(engine), engine); - assert.equal(engineFromViewer(null), null); - assert.equal(engineFromViewer(undefined), null); -}); - -test('isGlobeEngine tells engines from viewers', () => { - assert.equal(isGlobeEngine(createFakeEngine()), true); - assert.equal(isGlobeEngine(createFakeViewer()), false); - assert.equal(isGlobeEngine(null), false); - assert.equal(isGlobeEngine({ kind: 'cesium' }), false); -}); diff --git a/src/globe/cesium/events.js b/src/globe/cesium/events.js index 250977c4f..8aaabb3aa 100644 --- a/src/globe/cesium/events.js +++ b/src/globe/cesium/events.js @@ -8,20 +8,16 @@ import { createEngineEvent } from '../contract.js'; /** * @param {{ addEventListener: (listener: Function) => () => void }} source A * Cesium.Event (its addEventListener returns the remover the bridge uses). - * @param {((...args: any[]) => any[]) | null} [mapArgs] Cesium arguments → - * contract arguments. * @param {{ unary?: (...args: any[]) => any }} [options] `unary` maps the * Cesium arguments to the one contract argument without building an * argument list (for per-frame events). * @returns {{ event: import('../contract.js').EngineEvent, detach: () => void }} */ -export function bridgeEvent(source, mapArgs, { unary } = {}) { +export function bridgeEvent(source, { unary } = {}) { const event = createEngineEvent(); const forward = unary ? (a, b, c) => event.raiseValue(unary(a, b, c)) - : mapArgs - ? (...args) => event.raiseEvent(...mapArgs(...args)) - : (...args) => event.raiseEvent(...args); + : (...args) => event.raiseEvent(...args); let removeForward = null; let detached = false; const sync = () => { diff --git a/src/globe/cesium/events.test.mjs b/src/globe/cesium/events.test.mjs index 93fad04e2..8928132b5 100644 --- a/src/globe/cesium/events.test.mjs +++ b/src/globe/cesium/events.test.mjs @@ -23,7 +23,7 @@ test('a bridged event is a contract event that subscribes to Cesium lazily', () test('a bridged event maps Cesium arguments and keeps contract semantics', () => { const source = new Cesium.Event(); - const { event } = bridgeEvent(source, (scene, time) => [time * 2]); + const { event } = bridgeEvent(source, { unary: (scene, time) => time * 2 }); const seen = []; const listener = (value) => seen.push(value); event.addEventListener(listener); @@ -84,7 +84,7 @@ test('a bridge detaches through the remover the source returned', () => { test('a unary bridge raises one mapped value', () => { const source = new Cesium.Event(); - const { event, detach } = bridgeEvent(source, null, { + const { event, detach } = bridgeEvent(source, { unary: (scene, time) => time * 2, }); const seen = []; diff --git a/src/globe/cesium/frames.js b/src/globe/cesium/frames.js index 35692dd09..b46692ff8 100644 --- a/src/globe/cesium/frames.js +++ b/src/globe/cesium/frames.js @@ -31,7 +31,7 @@ export function createCesiumFrames(viewer) { const { scene } = viewer; const bridges = ['preUpdate', 'preRender', 'postRender'].map((name) => [ name, - bridgeEvent(scene[name], null, { unary: unixMilliseconds }), + bridgeEvent(scene[name], { unary: unixMilliseconds }), ]); return { frames: Object.fromEntries( diff --git a/src/globe/cesium/ground.js b/src/globe/cesium/ground.js index 058f7da1a..82b4745f6 100644 --- a/src/globe/cesium/ground.js +++ b/src/globe/cesium/ground.js @@ -106,7 +106,7 @@ czm_material czm_getMaterial(czm_materialInput materialInput) { }`; /** The uniforms of an animated corridor's material (pure, for tests). */ -export function corridorUniforms(item) { +function corridorUniforms(item) { const { glow, pulse, dash } = item.animation; return { color: colorOf(item.fill), @@ -146,7 +146,7 @@ const corridorMaterial = (item) => }); /** The radial disc material, including its soft outside edge. */ -export function radialMaterial(item) { +function radialMaterial(item) { return new Cesium.Material({ fabric: { type: 'GevGroundRadial', @@ -181,7 +181,7 @@ function positionsOf(flat) { const longitude = (degrees) => degrees - 360 * Math.round(degrees / 360); /** The fill geometry of one item of a set of `kind` (pure, for tests). */ -export function fillGeometry(kind, item) { +function fillGeometry(kind, item) { const vertexFormat = item.radial ? Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat : Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT; diff --git a/src/globe/cesium/groundPulse.js b/src/globe/cesium/groundPulse.js index ea57dd377..492e5ccbe 100644 --- a/src/globe/cesium/groundPulse.js +++ b/src/globe/cesium/groundPulse.js @@ -70,7 +70,7 @@ const toColor = ([red, green, blue, alpha]) => * @param {Cesium.Matrix4} [result] * @returns {Cesium.Matrix4} */ -export function discFollowMatrix(from, to, result = new Cesium.Matrix4()) { +function discFollowMatrix(from, to, result = new Cesium.Matrix4()) { const a = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3()); const b = Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()); const axis = Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3()); diff --git a/src/globe/cesium/imagery.js b/src/globe/cesium/imagery.js index 26eedcd5f..4d295f33f 100644 --- a/src/globe/cesium/imagery.js +++ b/src/globe/cesium/imagery.js @@ -27,7 +27,7 @@ const NO_HOST = Object.freeze({ collection: null, surface: 'none' }); /** Delay before the one frame that retries tiles a provider deferred. */ export const TILE_RETRY_DELAY_MS = 250; /** How often a staged source waiting out its quiet interval rechecks readiness. */ -export const STAGE_WAKE_MS = 150; +const STAGE_WAKE_MS = 150; // A staged source's own tiles are in once no request was made or settled // for this long; two frames in a row must find it so. const STAGE_QUIET_MS = 200; @@ -118,7 +118,7 @@ function singleImageProvider(image, rectangle, credit) { * for one frame when it settles), and a request the provider itself * deferred goes through one coalesced delayed retry. */ -export function admitTileRequests(provider, admission, requestRender, retry) { +function admitTileRequests(provider, admission, requestRender, retry) { const original = provider.requestImage.bind(provider); provider.requestImage = (x, y, level, request) => { if (admission.inFlight >= admission.limit) return undefined; diff --git a/src/globe/cesium/imageryShell.js b/src/globe/cesium/imageryShell.js index 7a3037a18..15eb57b6f 100644 --- a/src/globe/cesium/imageryShell.js +++ b/src/globe/cesium/imageryShell.js @@ -66,7 +66,7 @@ const sameEdges = (a, b) => /** Keep the shells first in the primitive collection, by rank: higher ranks * draw over lower ones and other opaque content draws over both. */ -export function orderImageryShells(primitives, primitive, rank) { +function orderImageryShells(primitives, primitive, rank) { ranks.set(primitive, rank); if (typeof primitives.lowerToBottom !== 'function') return; const shells = []; diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js index 1e244221e..f2d4d81fc 100644 --- a/src/globe/cesium/movers.js +++ b/src/globe/cesium/movers.js @@ -78,7 +78,7 @@ const nearFar = (value) => * A model set's colour blend as Cesium Model options. Without colorBlend the * instance colour only sets the alpha (MIX by 0), as on Navara. */ -export function modelBlend(colorBlend) { +function modelBlend(colorBlend) { if (!colorBlend) return { colorBlendMode: Cesium.ColorBlendMode.MIX, colorBlendAmount: 0 }; return colorBlend.mode === 'replace' diff --git a/src/globe/cesium/postStages.js b/src/globe/cesium/postStages.js index d56888b37..9ed7f3101 100644 --- a/src/globe/cesium/postStages.js +++ b/src/globe/cesium/postStages.js @@ -52,7 +52,7 @@ const BLOOM_UNIFORMS = [ * Samples a 3x3 neighborhood, computes box blur, then adds the * difference (center - blur) scaled by `amount` for edge enhancement. */ -export const SHARPEN_SHADER = /* glsl */ ` +const SHARPEN_SHADER = /* glsl */ ` uniform sampler2D colorTexture; uniform vec2 colorTextureDimensions; uniform float amount; diff --git a/src/globe/cesium/sceneProjection.js b/src/globe/cesium/sceneProjection.js index 1f53c148e..41e58d767 100644 --- a/src/globe/cesium/sceneProjection.js +++ b/src/globe/cesium/sceneProjection.js @@ -6,12 +6,3 @@ export function worldToWindowCoordinates(scene, position, result) { result, ); } -let occluder; -export function horizonOccluder(camera) { - occluder ??= new Cesium.EllipsoidalOccluder( - Cesium.Ellipsoid.WGS84, - new Cesium.Cartesian3(), - ); - occluder.cameraPosition = camera.positionWC; - return occluder; -} diff --git a/src/globe/cesium/trail.js b/src/globe/cesium/trail.js deleted file mode 100644 index 3d05688b6..000000000 --- a/src/globe/cesium/trail.js +++ /dev/null @@ -1,141 +0,0 @@ -/** - * @module trailRenderer - * @description Shared track-history renderer (PRD WS-F F4; rebuilt round 6, - * 2026-07-06). - * - * One trail = one ENTITY polyline. Round 6 replaced the faded per-vertex - * Primitive for two owner directives from the field: - * - "the line must ALWAYS be visible": the Primitive's depthFailAppearance - * did not reliably render segments below the photoreal mesh — entity - * polylines with `depthFailMaterial` DO (in-repo proof: CCTV's frustum - * wireframes read through geometry with exactly this), so occluded - * segments now draw dimmed instead of vanishing. - * - "show actual tracks, don't style them too much": the tail-fade is gone; - * the whole history renders at one readable alpha (dimmer where it passes - * behind/below geometry). - * - * Trails update at poll cadence (~15-60 s) plus once on history backfill, so - * assigning a fresh positions array per update is cheap. `allowPicking` has - * no entity equivalent; the polyline entity is excluded from clicks by never - * carrying a pick id the layers' click handlers resolve. - */ -import * as Cesium from 'cesium'; -import { registerPickOwner } from '../../data/pickRegistry.js'; - -// Round 6: trail ENTITIES are pickable (the old Primitive had -// allowPicking:false). A trail hugs its aircraft, so an unclaimed pick would -// read as "empty space" in every layer's click handler and deselect the very -// plane being tracked. Claiming the 'gev-trail:' id namespace makes -// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op -// everywhere. Registered once at module load; the predicate is pure. -registerPickOwner('trails', (pickedId) => - String(pickedId).startsWith('gev-trail:'), -); - -/** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */ -let _trailSeq = 0; - -/** @constant {number} Alpha where the trail passes the depth test. */ -const TRAIL_ALPHA = 0.85; -/** @constant {number} Alpha where the trail is behind/below scene geometry — - * visible (owner: never vanish) but readable as occluded. */ -const TRAIL_OCCLUDED_ALPHA = 0.4; -/** @constant {number} Squared distance (m^2) below which consecutive points are merged. */ -const MIN_SEGMENT_DISTANCE_SQ = 0.01; - -/** - * Create an always-visible polyline trail bound to a viewer. - * @param {Cesium.Viewer} viewer - Viewer whose entity collection owns the trail. - * @param {object} options - Trail options. - * @param {string} options.color - CSS color string for the trail hue. - * @param {number} [options.width=2.5] - Polyline width in pixels. - * @returns {{setPositions: function(Cesium.Cartesian3[]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}} - * Trail handle: setPositions replaces the geometry, setVisible temporarily - * hides it without discarding history, clear empties it, and destroy removes - * the entity permanently. - */ -export function createTrail(viewer, { color, width = 2.5 }) { - const baseColor = Cesium.Color.fromCssColorString(color); - /** @type {Cesium.Cartesian3[]} Current deduped positions (owned copy). */ - let current = []; - let destroyed = false; - let visible = true; - /** @type {Cesium.Entity|null} */ - let entity = null; - - function ensureEntity() { - if (entity || destroyed || !viewer || viewer.isDestroyed()) return; - entity = viewer.entities.add({ - id: `gev-trail:${++_trailSeq}`, - show: visible, - polyline: { - // CallbackProperty so a positions swap never rebuilds the entity — - // Cesium re-reads on change; `false` marks it non-constant. - positions: new Cesium.CallbackProperty(() => current, false), - width, - material: baseColor.withAlpha(TRAIL_ALPHA), - // The owner-locked rule (round 6): a segment below the photoreal - // mesh renders dimmed — it must never disappear into the ground. - depthFailMaterial: baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA), - // Round 8 (owner: UAL1104's Pacific trail "cutting through the - // globe"): NONE draws straight 3D chords between waypoints — over a - // sparse trans-oceanic trace a single segment spans hundreds of km - // and tunnels through the planet. GEODESIC subdivides each segment - // along the curved surface (heights interpolated), so long legs hug - // the globe instead of chording through it. - arcType: Cesium.ArcType.GEODESIC, - }, - }); - } - - return { - /** - * Replace the trail geometry with a chronological position list - * (oldest first). Fewer than 2 distinct positions clears the trail. - * @param {Cesium.Cartesian3[]} cartesians - Positions, oldest -> newest. - */ - setPositions(cartesians) { - if (destroyed || !viewer || viewer.isDestroyed()) return; - // Drop consecutive near-duplicates: zero-length segments add nothing. - const positions = []; - for (const position of Array.isArray(cartesians) ? cartesians : []) { - if (!position) continue; - const last = positions[positions.length - 1]; - if ( - last && - Cesium.Cartesian3.distanceSquared(last, position) < - MIN_SEGMENT_DISTANCE_SQ - ) - continue; - positions.push(position); - } - current = positions.length >= 2 ? positions : []; - ensureEntity(); - }, - - /** Temporarily hide/show the trail without discarding accumulated history. */ - setVisible(nextVisible) { - visible = nextVisible !== false; - if (entity) entity.show = visible; - }, - - /** Empty the trail without removing the entity (cheap re-arm). */ - clear() { - current = []; - }, - - /** Remove the trail entity permanently (layer disable/teardown). */ - destroy() { - destroyed = true; - current = []; - if (entity && viewer && !viewer.isDestroyed()) { - try { - viewer.entities.remove(entity); - } catch { - /* torn down */ - } - } - entity = null; - }, - }; -} diff --git a/src/globe/cesium/zoomOutGuard.js b/src/globe/cesium/zoomOutGuard.js index c3ac9a6b8..52dc5d315 100644 --- a/src/globe/cesium/zoomOutGuard.js +++ b/src/globe/cesium/zoomOutGuard.js @@ -3,7 +3,7 @@ import * as Cesium from 'cesium'; /** Whole-Earth input limit; explicit camera destinations can go farther. */ export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000; /** Below this, oblique browsing is left alone. */ -export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000; +const PITCH_CLAMP_MIN_HEIGHT_M = 300_000; // Plain numbers: on a page that starts on Navara the Cesium SDK loads only // with the first Cesium globe, after this module (src/app/loadCesium.js). const RADIANS_PER_DEGREE = Math.PI / 180; diff --git a/src/globe/contract.js b/src/globe/contract.js index 6727e7c14..e77b0821a 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -1083,10 +1083,6 @@ const OPTIONAL_SHAPE = { }, }; -// Required capabilities engineCapabilities reports as well: true for an -// engine that has them whole, as every valid engine does. -const REPORTED_REQUIRED = ['movers', 'lines', 'ground', 'imagery', 'meshes']; - const memberAt = (engine, path) => path.split('.').reduce((value, key) => value?.[key], engine); @@ -1094,26 +1090,20 @@ const memberAt = (engine, path) => * Which optional capabilities an engine has: { visualStyles, ... } as * booleans. A caller that refuses a feature on an engine without one * (voice, say) asks here. Cesium and Navara draw the styles, bloom and - * contact sonar; only Navara has cloudCover. `movers`, `lines`, `ground` - * and `imagery`, plus `meshes`, are required; they are reported too, true for every valid - * engine. + * contact sonar; only Navara has cloudCover. */ export function engineCapabilities(engine) { - const whole = (members, prefix = '') => + const whole = (members) => isObject(engine) && Object.entries(members).every(([path, valid]) => - valid(memberAt(engine, prefix + path)), + valid(memberAt(engine, path)), ); - return Object.fromEntries([ - ...Object.entries(OPTIONAL_SHAPE).map(([flag, members]) => [ + return Object.fromEntries( + Object.entries(OPTIONAL_SHAPE).map(([flag, members]) => [ flag, whole(members), ]), - ...REPORTED_REQUIRED.map((capability) => [ - capability, - whole(SHAPE[capability], `${capability}.`), - ]), - ]); + ); } /** diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index 9256570f7..6905eb1d0 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -293,19 +293,14 @@ test('mergeSharpen keeps omitted fields and rejects bad values', () => { assert.throws(() => mergeSharpen(on, bad), TypeError); }); -test('movers and lines are required and reported as capabilities', () => { +test('movers and lines are required', () => { const engine = createFakeEngine(); - assert.equal(engineCapabilities(engine).movers, true); - assert.equal(engineCapabilities(engine).lines, true); const without = { ...engine }; delete without.movers; delete without.lines; assert.throws(() => assertGlobeEngine(without), /\bmovers\b.*\blines\b/); - assert.equal(engineCapabilities(without).movers, false); - assert.equal(engineCapabilities(without).lines, false); const half = { ...engine, movers: { createSet: () => {} } }; assert.throws(() => assertGlobeEngine(half), /movers\.list/); - assert.equal(engineCapabilities(half).movers, false); // The suite draws movers and lines on every engine. const names = createContractSuite(engine, { waitFrames: async () => {}, @@ -317,13 +312,11 @@ test('movers and lines are required and reported as capabilities', () => { ); }); -test('ground is required and reported as a capability', () => { +test('ground is required', () => { const engine = createFakeEngine(); - assert.equal(engineCapabilities(engine).ground, true); const without = { ...engine }; delete without.ground; assert.throws(() => assertGlobeEngine(without), /\bground\b/); - assert.equal(engineCapabilities(without).ground, false); const half = { ...engine, ground: { createSet: () => {} } }; assert.throws( () => assertGlobeEngine(half), @@ -331,13 +324,11 @@ test('ground is required and reported as a capability', () => { ); }); -test('imagery is required and reported as a capability', () => { +test('imagery is required', () => { const engine = createFakeEngine(); - assert.equal(engineCapabilities(engine).imagery, true); const without = { ...engine }; delete without.imagery; assert.throws(() => assertGlobeEngine(without), /\bimagery\b/); - assert.equal(engineCapabilities(without).imagery, false); const half = { ...engine, imagery: { createLayer: () => {} } }; assert.throws(() => assertGlobeEngine(half), /imagery\.list/); const names = createContractSuite(engine, { @@ -346,9 +337,8 @@ test('imagery is required and reported as a capability', () => { assert.ok(names.some((name) => name.startsWith('imagery:'))); }); -test('meshes are required and reported as a capability', () => { +test('meshes are required', () => { const engine = createFakeEngine(); - assert.equal(engineCapabilities(engine).meshes, true); assert.throws( () => assertGlobeEngine({ ...engine, meshes: undefined }), /meshes/, diff --git a/src/globe/ground.js b/src/globe/ground.js index 61cfcf7a3..78c961b76 100644 --- a/src/globe/ground.js +++ b/src/globe/ground.js @@ -9,13 +9,8 @@ import * as geo from '../geo/index.js'; import { createGroundPulseRegistry } from './groundPulse.js'; -export const GROUND_KINDS = Object.freeze([ - 'polygon', - 'disc', - 'cell', - 'corridor', -]); -export const GROUND_DRAPES = Object.freeze(['both', 'terrain', 'tiles']); +const GROUND_KINDS = Object.freeze(['polygon', 'disc', 'cell', 'corridor']); +const GROUND_DRAPES = Object.freeze(['both', 'terrain', 'tiles']); const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value); @@ -382,20 +377,6 @@ export function pulseFactor({ periodMs, min, max }, seconds) { /** Half the soft edge of a dash, in cells (the route flow material's). */ export const DASH_EDGE = 0.08; -/** - * How lit a dash is (0..1) at `along` (0 at the corridor's start, 1 at its - * end) and `seconds`: the cells scroll toward the end at `speed` a second. - */ -export function dashLit({ cells, duty, speed }, along, seconds) { - const flow = along * cells - seconds * speed; - const at = flow - Math.floor(flow); - const t = Math.min( - 1, - Math.max(0, (at - (duty - DASH_EDGE)) / (2 * DASH_EDGE)), - ); - return 1 - t * t * (3 - 2 * t); -} - /** A disc's boundary as a ring of `segments` points (degrees, flat). */ export function discRing({ center, radiusM }, segments = 64) { const origin = geo.cartesian3.fromDegrees(center[0], center[1]); diff --git a/src/globe/ground.test.mjs b/src/globe/ground.test.mjs index 2b6862a3e..18e702f27 100644 --- a/src/globe/ground.test.mjs +++ b/src/globe/ground.test.mjs @@ -6,7 +6,6 @@ import { cellRing, createGroundPicks, createGroundRegistry, - dashLit, discRing, normalizeGroundItem, normalizeGroundSetOptions, @@ -288,18 +287,11 @@ test("a pixel corridor's glow, pulse and dash are checked and defaulted", () => ); }); -test('a pulse swings between its min and max; a dash scrolls toward the end', () => { +test('a pulse swings between its min and max', () => { const pulse = { periodMs: 2000, min: 0.2, max: 0.8 }; assert.ok(Math.abs(pulseFactor(pulse, 0) - 0.5) < 1e-9); assert.ok(Math.abs(pulseFactor(pulse, 0.5) - 0.8) < 1e-9); assert.ok(Math.abs(pulseFactor(pulse, 1.5) - 0.2) < 1e-9); - const dash = { cells: 10, duty: 0.5, speed: 1 }; - // Lit for the first half of each cell, dark for the second, soft edges. - assert.equal(dashLit(dash, 0.02, 0), 1); - assert.equal(dashLit(dash, 0.08, 0), 0); - assert.ok(Math.abs(dashLit(dash, 0.05, 0) - 0.5) < 1e-9); - // A second later the pattern has moved one cell toward the end. - assert.equal(dashLit(dash, 0.12, 1), dashLit(dash, 0.02, 0)); }); test('the registry knows when a shown set animates', () => { diff --git a/src/globe/imagery.js b/src/globe/imagery.js index 854ec2850..f7a1ab11a 100644 --- a/src/globe/imagery.js +++ b/src/globe/imagery.js @@ -8,16 +8,11 @@ // that mosaic their own tiles (Navara). Rectangles are degrees; colours are // the image's own. No engine imports. -export const IMAGERY_SOURCE_KINDS = Object.freeze(['tiles', 'image']); -export const IMAGERY_SPLITS = Object.freeze(['none', 'left', 'right']); +const IMAGERY_SOURCE_KINDS = Object.freeze(['tiles', 'image']); +const IMAGERY_SPLITS = Object.freeze(['none', 'left', 'right']); /** How a tiles source cuts the globe: Web Mercator XYZ, or geographic * (EPSG:4326, 2×1 tiles at level 0, Cesium's GeographicTilingScheme). */ -export const IMAGERY_TILE_SCHEMES = Object.freeze([ - 'webMercator', - 'geographic', -]); -/** Where imagery drapes now (GlobeImagery.surface). */ -export const IMAGERY_SURFACES = Object.freeze(['globe', 'tiles', 'none']); +const IMAGERY_TILE_SCHEMES = Object.freeze(['webMercator', 'geographic']); /** The latitude Web Mercator tiles stop at. */ export const WEB_MERCATOR_LIMIT = 85.05112878; @@ -496,7 +491,6 @@ export const mercatorY = (lat) => { }; /** Geographic tiles: x across 2^(z+1) columns, y down 2^z rows from 90°. */ -export const geographicX = (lon) => (lon + 180) / 360; export const geographicY = (lat) => (90 - Math.max(-90, Math.min(90, lat))) / 180; @@ -521,7 +515,7 @@ export function imageryTilePlan(box, rectangle, level, scheme = 'webMercator') { }; } if (area.west >= area.east || area.south >= area.north) return null; - const toX = geographic ? geographicX : mercatorX; + const toX = mercatorX; const toY = geographic ? geographicY : mercatorY; for (let z = Math.max(0, Math.floor(level)); z >= 0; z--) { const columns = geographic ? 2 ** (z + 1) : 2 ** z; @@ -600,7 +594,6 @@ export function imageryTileLevel({ */ export function createImageryTileQueue({ load, limit = 6 }) { let active = 0, - peak = 0, destroyed = false; const pending = [], controllers = new Set(); @@ -612,7 +605,6 @@ export function createImageryTileQueue({ load, limit = 6 }) { controllers.add(controller); task.controllers.add(controller); active++; - peak = Math.max(peak, active); Promise.resolve() .then(() => load(task.url, controller.signal)) .then((image) => { @@ -646,11 +638,6 @@ export function createImageryTileQueue({ load, limit = 6 }) { for (let i = pending.length - 1; i >= 0; i--) if (!pending[i].live()) pending.splice(i, 1); }, - diagnostics: () => ({ - active, - queued: pending.filter((t) => t.live()).length, - peak, - }), destroy() { destroyed = true; pending.length = 0; diff --git a/src/globe/imagery.test.mjs b/src/globe/imagery.test.mjs index 73fb655c8..7d03d0142 100644 --- a/src/globe/imagery.test.mjs +++ b/src/globe/imagery.test.mjs @@ -430,13 +430,13 @@ test('six admissions include slow aborted decodes; stale images close without dr queue.add(Array.from({ length: 10 }, (_, i) => task(i))); await new Promise((resolve) => setImmediate(resolve)); assert.equal(pending.length, 6); - assert.equal(queue.diagnostics().active, 6); + assert.equal(pending.length, 6); live = false; for (const c of controllers) c.abort(); queue.prune(); let replacement = true; queue.add([{ ...task(10), live: () => replacement }]); - assert.equal(queue.diagnostics().active, 6); + assert.equal(pending.length, 6); for (const p of pending) p.resolve({ close: () => closed.push(p.url) }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(pending.length, 7); @@ -449,5 +449,6 @@ test('six admissions include slow aborted decodes; stale images close without dr assert.deepEqual(drawn, []); assert.deepEqual(failed, []); assert.equal(closed.length, 7); - assert.deepEqual(queue.diagnostics(), { active: 0, queued: 0, peak: 6 }); + assert.equal(controllers.size, 0); + assert.equal(pending.length, 7, 'stale queued tasks never load'); }); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 9a8847386..a8a3b8adc 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -31,51 +31,10 @@ import { createGroundPicks } from '../ground.js'; import { navaraFollowGestures } from './followGestures.js'; import { createNavaraMaps } from './maps.js'; import { createNavaraSurface } from './surface.js'; -import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js'; +import { createGroundFloor } from './groundFloor.js'; const { cartesian3, math } = geo; -// engine.movers without the view's three runtime (Node tests of the engine). -const NO_MOVERS = Object.freeze({ - createSet() { - throw new Error("Navara movers need the view's three runtime (movers/)"); - }, - list: () => [], -}); -const NO_LINES = Object.freeze({ - createSegments() { - throw new Error("Navara lines need the view's three runtime (lines/)"); - }, - createLine() { - throw new Error("Navara lines need the view's three runtime (lines/)"); - }, - list: () => [], -}); -const NO_MESHES = Object.freeze({ - createMesh() { - throw new Error('Navara meshes need the view runtime'); - }, - list: () => [], -}); -const NO_GROUND = Object.freeze({ - createPulseField() { - throw new Error('Navara ground needs the view runtime'); - }, - createSet() { - throw new Error("Navara ground needs the view's three runtime (ground/)"); - }, - list: () => [], -}); -const NO_IMAGERY = Object.freeze({ - createLayer() { - throw new Error("Navara imagery needs the view's three runtime (imagery/)"); - }, - list: () => [], - setSplitPosition() {}, - splitPosition: () => 0.5, - surface: () => 'globe', -}); - // Navara's drawn camera (view.camera.raw, a three PerspectiveCamera in // Earth-fixed metres): matrixWorld columns are right, up, back, position. function readCamera(raw) { @@ -94,7 +53,7 @@ function readCamera(raw) { * @param {object} view An initialised Navara ThreeView. * @param {{ * hooks: { tileRoots: Function, isIdle: Function, retainTileGeometry: Function, dropOrphanFeatureChanges?: Function }, - * imagery?: (parts: { camera: object, size: Function, credits: object, photorealActive: () => boolean }) => { imagery: object, diagnostics: Function, destroy: Function }, + * imagery: (parts: { camera: object, size: Function, credits: object, photorealActive: () => boolean }) => { imagery: object, destroy: Function }, * container?: HTMLElement, * fetch?: typeof fetch, * clock?: object, @@ -117,11 +76,11 @@ function readCamera(raw) { * capability), destroyed with the engine; * `appClock` (the engine clock's time) defaults to the page's app clock; * `movers` is engine.movers on the view's three runtime (movers/index.js; - * without it createSet throws), `lines` engine.lines (lines/index.js; - * likewise), `ground` engine.ground (ground/index.js; likewise), all + * required), `lines` engine.lines (lines/index.js), `ground` engine.ground + * (ground/index.js), all * destroyed with the engine; `imagery` makes engine.imagery from the * engine's camera, size, credits and photoreal state (imagery/index.js; - * without it createLayer throws), destroyed with the engine; + * required), destroyed with the engine; * `fetch` and `clock` (the camera model's timers) are test seams. * @returns {import('../contract.js').GlobeEngine} */ @@ -133,11 +92,11 @@ export function createNavaraEngine( fetch, clock, appClock, - movers: moversPort = null, - lines: linesPort = null, - ground: groundPort = null, - imagery: imageryPort = null, - meshes: meshesPort = null, + movers: moversPort, + lines: linesPort, + ground: groundPort, + imagery: imageryPort, + meshes: meshesPort, sharpen: sharpenPort = null, styles: stylesPort = null, bloom: bloomPort = null, @@ -451,11 +410,11 @@ export function createNavaraEngine( credits: credits.credits, maps: maps.maps, clock: engineClock.clock, - movers: moversPort?.movers ?? NO_MOVERS, - lines: linesPort?.lines ?? NO_LINES, - ground: groundPort?.ground ?? NO_GROUND, - meshes: meshes?.meshes ?? NO_MESHES, - imagery: imagery?.imagery ?? NO_IMAGERY, + movers: moversPort.movers, + lines: linesPort.lines, + ground: groundPort.ground, + meshes: meshes.meshes, + imagery: imagery.imagery, destroy() { if (destroyed) return; destroyed = true; @@ -494,13 +453,5 @@ export function createNavaraEngine( }, // Navara only, not contract: what the ground pass holds (QA reads it). groundDiagnostics: () => groundPort?.diagnostics?.() ?? null, - // Navara only, not contract: what each imagery layer holds. - imageryDiagnostics: () => imagery?.diagnostics?.() ?? null, - // Navara only, not contract: the ground the floor last sampled under - // the camera and the margin it keeps above it (QA measures against it). - groundFloor: { - ground: () => groundFloor.ground(), - margin: GROUND_FLOOR.MIN_ABOVE, - }, }; } diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index 3ba9ea9b8..ad54dd870 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -100,9 +100,58 @@ const hooks = { retainTileGeometry() {}, }; +// Primitive ports for tests of engine orchestration. +const STUB_MOVERS = Object.freeze({ + createSet() { + throw new Error("Navara movers need the view's three runtime (movers/)"); + }, + list: () => [], +}); +const STUB_LINES = Object.freeze({ + createSegments() { + throw new Error("Navara lines need the view's three runtime (lines/)"); + }, + createLine() { + throw new Error("Navara lines need the view's three runtime (lines/)"); + }, + list: () => [], +}); +const STUB_MESHES = Object.freeze({ + createMesh() { + throw new Error('Navara meshes need the view runtime'); + }, + list: () => [], +}); +const STUB_GROUND = Object.freeze({ + createPulseField() { + throw new Error('Navara ground needs the view runtime'); + }, + createSet() { + throw new Error("Navara ground needs the view's three runtime (ground/)"); + }, + list: () => [], +}); +const STUB_IMAGERY = Object.freeze({ + createLayer() { + throw new Error("Navara imagery needs the view's three runtime (imagery/)"); + }, + list: () => [], + setSplitPosition() {}, + splitPosition: () => 0.5, + surface: () => 'globe', +}); + +const ports = { + movers: { movers: STUB_MOVERS, destroy() {} }, + lines: { lines: STUB_LINES, destroy() {} }, + ground: { ground: STUB_GROUND, destroy() {} }, + imagery: () => ({ imagery: STUB_IMAGERY, destroy() {} }), + meshes: () => ({ meshes: STUB_MESHES, destroy() {} }), +}; + const setup = () => { const view = fakeView(); - const engine = createNavaraEngine(view, { hooks }); + const engine = createNavaraEngine(view, { ...ports, hooks }); return { view, engine }; }; @@ -135,7 +184,7 @@ test('the engine clock is the app clock, latched on the view preUpdate before fr let wall = 2_000_000; const appClock = createAppClock({ now: () => wall }); const view = fakeView(); - const engine = createNavaraEngine(view, { hooks, appClock }); + const engine = createNavaraEngine(view, { ...ports, hooks, appClock }); assert.equal(engine.clock.now(), 2_000_000); const seen = []; for (const name of ['preUpdate', 'preRender', 'postRender']) @@ -177,6 +226,7 @@ test('setSharpen hands changed states to the sharpen port and draws a frame', () const shown = []; let destroyed = 0; const engine = createNavaraEngine(view, { + ...ports, hooks, sharpen: { set: (state) => shown.push(state), @@ -225,7 +275,7 @@ function stylesPort() { test('setStyle hands changed style states to the styles port and draws a frame', () => { const view = fakeView(); const port = stylesPort(); - const engine = createNavaraEngine(view, { hooks, styles: port }); + const engine = createNavaraEngine(view, { ...ports, hooks, styles: port }); assertGlobeEngine(engine); assert.equal(engineCapabilities(engine).visualStyles, true); assert.deepEqual(engine.render.visualStyles, ['retro', 'noir']); @@ -267,6 +317,7 @@ test('setBloom hands changed states to the bloom port and draws a frame', () => const shown = []; let destroyed = 0; const engine = createNavaraEngine(view, { + ...ports, hooks, bloom: { set: (state) => shown.push(state), destroy: () => destroyed++ }, }); @@ -294,6 +345,7 @@ test('setCloudCover hands changed covers to the clouds port and draws a frame', const shown = []; let destroyed = 0; const engine = createNavaraEngine(view, { + ...ports, hooks, clouds: { set: (state) => shown.push(state), destroy: () => destroyed++ }, }); @@ -317,6 +369,7 @@ test('setContactSonar hands changes to the sonar port, asking a frame only outsi const shown = []; let destroyed = 0; const engine = createNavaraEngine(view, { + ...ports, hooks, sonar: { set: (state) => shown.push(state), destroy: () => destroyed++ }, }); @@ -591,10 +644,3 @@ test('the floor stands aside while the camera inputs are suspended (the cockpit near(heightOf(engine), 130, 1e-6, 'lifted once the inputs are back'); engine.destroy(); }); - -test('the ground floor reports its own ground and margin (diagnostics, not contract)', () => { - const { engine } = setup(); - assert.equal(typeof engine.groundFloor.ground, 'function'); - assert.equal(engine.groundFloor.margin, 30); - assert.equal(engine.groundFloor.ground(), undefined, 'nothing sampled yet'); -}); diff --git a/src/globe/navara/frameDescriptor.js b/src/globe/navara/frameDescriptor.js index ca5bed500..771a6bfc2 100644 --- a/src/globe/navara/frameDescriptor.js +++ b/src/globe/navara/frameDescriptor.js @@ -33,12 +33,3 @@ export function withPassDisposal(Base) { } }; } - -// The base the layers' Navara effects extend (engine.LayerEffectDesc): per -// frame updates on their own callback, and the raw pass disposed when the -// effect is deleted. A layer re-creates its effect on every attach (an -// engine switch), so an undisposed pass kept its material, its shader -// program and the uniforms' data textures alive for the page's lifetime. -export function layerEffectDesc(EffectDesc) { - return withPassDisposal(withFrameUpdates(EffectDesc)); -} diff --git a/src/globe/navara/frameDescriptor.test.mjs b/src/globe/navara/frameDescriptor.test.mjs index 3d1f14e63..03128299d 100644 --- a/src/globe/navara/frameDescriptor.test.mjs +++ b/src/globe/navara/frameDescriptor.test.mjs @@ -1,7 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - layerEffectDesc, withFrameUpdates, withPassDisposal, } from './frameDescriptor.js'; @@ -81,46 +80,3 @@ test('a deleted effect disposes the pass the composer no longer holds', () => { assert.throws(() => new (withPassDisposal(Failing))().onDestroy(), /removal/); assert.deepEqual(events, ['dispose']); }); - -test('a layer effect re-created on every attach leaves no pass, material or frame callback behind', () => { - const frames = new Set(); - const view = { - on: (name, cb) => frames.add(cb), - off: (name, cb) => frames.delete(cb), - }; - const live = new Set(); - class EffectDesc { - view = view; - onCreate() { - const pass = { - material: {}, - dispose: () => live.delete(pass), - }; - live.add(pass); - this._instance = { rawPass: pass }; - } - get raw() { - return this._instance; - } - onDestroy() { - // Navara's removePass: out of the composer, not disposed. - this._instance = undefined; - } - } - // As the earthquakes ground effect: disposes its own effect, then super. - class LayerEffect extends layerEffectDesc(EffectDesc) { - updateFrame() {} - onDestroy() { - this.effectDisposed = true; - super.onDestroy(); - } - } - for (let attach = 0; attach < 50; attach++) { - const effect = new LayerEffect(); - effect.onCreate(); - effect.onDestroy(); - assert.equal(effect.effectDisposed, true); - } - assert.equal(live.size, 0, 'every pass of a deleted effect is disposed'); - assert.equal(frames.size, 0, 'no frame callback outlives its effect'); -}); diff --git a/src/globe/navara/ground/corridors.js b/src/globe/navara/ground/corridors.js index 2c47d2757..06a6eb6a4 100644 --- a/src/globe/navara/ground/corridors.js +++ b/src/globe/navara/ground/corridors.js @@ -378,7 +378,7 @@ export function tileCorridors( * `reachOf(item)` is the item's [half width in pixels + 0.5, half width in * metres]; `reference` the set's least pixel reach. */ -export function packTile(tile, pieces, items, frame, reachOf, reference) { +export function packTile(tile, pieces, frame, reference) { const { project } = frame; // Its region in the plane, from its edges. let x0 = Infinity; diff --git a/src/globe/navara/ground/pack.js b/src/globe/navara/ground/pack.js index d60a2e99e..e64c1f780 100644 --- a/src/globe/navara/ground/pack.js +++ b/src/globe/navara/ground/pack.js @@ -292,7 +292,7 @@ export function packSet( (tile.west + tile.east) / 2, (tile.south + tile.north) / 2, ); - const packed = packTile(tile, pieces, items, frame, reachOf, reference); + const packed = packTile(tile, pieces, frame, reference); // Runs and bins relative to the tile's own: its style holds where // they start in the set's. const runStart = runs.length / 4; diff --git a/src/globe/navara/ground/pass.js b/src/globe/navara/ground/pass.js index 7e1c8812c..980017f8a 100644 --- a/src/globe/navara/ground/pass.js +++ b/src/globe/navara/ground/pass.js @@ -381,14 +381,7 @@ export const textureBytes = (state) => class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) { static key = GROUND_EFFECT_KEY; // Navara takes the first anchor that exists: the latest first. - static insertAfter = [ - 'gevFirmsGround', - 'gevEarthquakeGround', - 'gevObservedWeather', - 'gevNepalEvent', - 'gevImagery', - 'toneMapping', - ]; + static insertAfter = ['gevImagery', 'toneMapping']; static insertBefore = [ 'gevPreSharpenOverlay', 'gevSharpen', diff --git a/src/globe/navara/ground/pulse.js b/src/globe/navara/ground/pulse.js index 2f3f3138f..206a02c7e 100644 --- a/src/globe/navara/ground/pulse.js +++ b/src/globe/navara/ground/pulse.js @@ -85,13 +85,7 @@ const pulseEffect = defineGroundEffect({ key: KEY, name: 'GevGroundPulses', fragmentShader, - insertAfter: [ - 'gevGround', - 'gevObservedWeather', - 'gevNepalEvent', - 'gevImagery', - 'toneMapping', - ], + insertAfter: ['gevGround', 'gevImagery', 'toneMapping'], insertBefore: [ 'gevPreSharpenOverlay', 'gevSharpen', diff --git a/src/globe/navara/ground/surface.js b/src/globe/navara/ground/surface.js index 7616d31d1..57ec54ed0 100644 --- a/src/globe/navara/ground/surface.js +++ b/src/globe/navara/ground/surface.js @@ -1,31 +1,5 @@ // Public depth inputs shared by actual terrain/tiles classifiers. -import { - DataTexture, - FloatType, - RGBAFormat, - NearestFilter, - Matrix4, - Uniform, - RGBADepthPacking, -} from 'three'; -export function createGroundTexture(data, width, height) { - const texture = new DataTexture(data, width, height, RGBAFormat, FloatType); - texture.minFilter = NearestFilter; - texture.magFilter = NearestFilter; - texture.needsUpdate = true; - return texture; -} -export function createGroundStorage(texels) { - const width = Math.min(1024, Math.max(1, texels)); - const height = Math.max(1, Math.ceil(texels / width)); - const data = new Float32Array(width * height * 4); - return { - data, - width, - height, - texture: createGroundTexture(data, width, height), - }; -} +import { Matrix4, Uniform, RGBADepthPacking } from 'three'; export function groundSurfaceUniforms() { return [ ['tAllDepth', new Uniform(null)], diff --git a/src/globe/navara/ground/vector.js b/src/globe/navara/ground/vector.js deleted file mode 100644 index 00848d61d..000000000 --- a/src/globe/navara/ground/vector.js +++ /dev/null @@ -1,147 +0,0 @@ -// A prototype of engine.ground on Navara's own vector layers, kept to -// measure against the screen pass (pass.js) and not used by the engine: -// each set's items grouped by style into one GeoJSON source and one -// clamped vector layer per group (discs and cells as polygons, pixel -// corridors as polylines; metre corridors are not drawn). `hide` picks how a -// hidden set goes: 'delete' (its layers deleted, rebuilt when shown) or -// 'update' (layer.update with show false). - -import { areaRings, createGroundRegistry } from '../../ground.js'; - -const toHex = (rgba) => - (Math.round(rgba[0] * 255) << 16) | - (Math.round(rgba[1] * 255) << 8) | - Math.round(rgba[2] * 255); - -function ringCoordinates(flat) { - const ring = []; - for (let index = 0; index < flat.length; index += 2) - ring.push([flat[index], flat[index + 1]]); - ring.push(ring[0]); - return ring; -} - -// Features grouped by their layer style: Map. -export function vectorGroups(kind, items) { - const groups = new Map(); - const add = (key, style, feature) => { - if (!groups.has(key)) groups.set(key, { style, features: [] }); - groups.get(key).features.push(feature); - }; - items.forEach((item, index) => { - if (kind === 'corridor') { - if (!item.widthPx) return; - const coordinates = []; - for (let at = 0; at < item.path.length; at += 2) - coordinates.push([item.path[at], item.path[at + 1]]); - add( - `line:${item.fill.join()}:${item.widthPx}`, - { - polyline: { - color: toHex(item.fill), - width: item.widthPx, - opacity: item.fill[3], - transparent: item.fill[3] < 1, - clampToGround: true, - }, - }, - { - type: 'Feature', - id: index, - properties: {}, - geometry: { type: 'LineString', coordinates }, - }, - ); - return; - } - const rings = areaRings(kind, item).map(ringCoordinates); - const fill = item.fill ?? [0, 0, 0, 0]; - const outline = item.outline; - add( - `area:${fill.join()}:${outline ? `${outline.color.join()}:${outline.widthPx}` : ''}`, - { - polygon: { - color: toHex(fill), - opacity: fill[3], - transparent: true, - clampToGround: true, - ...(outline - ? { - outline: true, - outlineShow: true, - outlineColor: toHex(outline.color), - outlineWidth: outline.widthPx, - } - : {}), - }, - }, - { - type: 'Feature', - id: index, - properties: {}, - geometry: { type: 'Polygon', coordinates: rings }, - }, - ); - }); - return groups; -} - -/** - * @param {object} view an initialised Navara ThreeView - * @param {{ hide?: 'delete'|'update' }} [options] - */ -export function createNavaraVectorGround(view, { hide = 'delete' } = {}) { - const registry = createGroundRegistry((options) => { - let groups = new Map(); - let batches = []; - let shown = true; - const release = () => { - for (const { layer, source } of batches) { - layer.delete(); - source.delete(); - } - batches = []; - }; - const build = () => { - release(); - for (const { style, features } of groups.values()) { - const source = view.addSource({ - type: 'geojson', - data: { type: 'FeatureCollection', features }, - }); - const config = { type: 'vector', source, ...style }; - batches.push({ source, config, layer: view.addLayer(config) }); - } - }; - return { - commit(items) { - groups = vectorGroups(options.kind, items); - if (shown || hide === 'update') build(); - if (!shown && hide === 'update') - for (const batch of batches) - batch.layer.update({ ...batch.config, show: false }); - view.forceUpdate(); - }, - show(visible) { - if (visible === shown) return; - shown = visible; - if (hide === 'delete') { - if (visible) build(); - else release(); - } else - for (const batch of batches) - batch.layer.update({ ...batch.config, show: visible }); - view.forceUpdate(); - }, - destroy() { - release(); - view.forceUpdate(); - }, - }; - }); - return { - ground: registry.ground, - pickables: () => [...registry.live.values()], - destroy: () => registry.destroyAll(), - }; -} diff --git a/src/globe/navara/imagery/index.js b/src/globe/navara/imagery/index.js index 903d50999..26964b14c 100644 --- a/src/globe/navara/imagery/index.js +++ b/src/globe/navara/imagery/index.js @@ -107,7 +107,7 @@ const unwrapped = ({ west, south, east, north }) => [ * replace); `load` (a tile or an image URL to a decoded image) and * `createCanvas` are test seams. * @returns {{ imagery: import('../../contract.js').GlobeImagery, - * diagnostics: () => object, destroy: () => void }} + * destroy: () => void }} */ export function createNavaraImagery( view, @@ -323,12 +323,6 @@ export function createNavaraImagery( detail?.held.texture.dispose(); detail = null; }, - diagnostics: () => ({ - kind: 'image', - loaded: drawn !== null, - staging: staging !== null, - detail: detail !== null, - }), }; } @@ -457,17 +451,6 @@ export function createNavaraImagery( queue.destroy(); disposeAtlas(); }, - diagnostics: () => ({ - ...queue.diagnostics(), - atlas: atlas - ? { - level: atlas.plan.z, - tiles: atlas.plan.width * atlas.plan.height, - loaded: atlas.loaded, - failed: atlas.failed, - } - : null, - }), }; } @@ -551,11 +534,6 @@ export function createNavaraImagery( tiler?.dispose(); tiler = null; }, - diagnostics: () => ({ - kind: 'tiles', - ...(tiler?.diagnostics() ?? {}), - staging: staged ? staged.tiler.diagnostics() : null, - }), }; } @@ -659,16 +637,6 @@ export function createNavaraImagery( return { imagery: registry.imagery, - /** Not contract: what each layer holds (QA reads it). */ - diagnostics: () => - Object.fromEntries( - registry - .ordered() - .map((state) => [ - state.options.id, - entries.get(state)?.drawing.diagnostics(), - ]), - ), destroy() { offCamera(); registry.destroyAll(); diff --git a/src/globe/navara/imagery/index.test.mjs b/src/globe/navara/imagery/index.test.mjs index d2f92bdca..7e167d4a9 100644 --- a/src/globe/navara/imagery/index.test.mjs +++ b/src/globe/navara/imagery/index.test.mjs @@ -18,7 +18,7 @@ function fixture() { const view = { forceUpdate: () => frames++, }; - const { imagery, diagnostics, destroy } = createNavaraImagery(view, { + const { imagery, destroy } = createNavaraImagery(view, { camera: { changed, pose: () => ({ @@ -53,7 +53,6 @@ function fixture() { }); return { imagery, - diagnostics, destroy, canvases, draws, @@ -80,10 +79,10 @@ test('a tiles layer mosaics the visible tiles into one atlas; a new source drops const f = fixture(); const layer = f.imagery.createLayer({ id: 'a', source: tiles() }); await tick(); - const first = f.diagnostics().a; - assert.equal(first.atlas.level, 12); - assert.ok(first.atlas.tiles >= 1 && first.atlas.tiles <= 64); - assert.equal(f.jobs.length, Math.min(6, first.atlas.tiles), 'six at a time'); + const canvas = f.canvases[0]; + const count = (canvas.width / 256) * (canvas.height / 256); + assert.ok(count >= 1 && count <= 64); + assert.equal(f.jobs.length, Math.min(6, count), 'six at a time'); assert.match( f.jobs[0].url, /^https:\/\/gibs-[abc]\.example\/12\/\d+\/\d+\.png$/, @@ -189,13 +188,13 @@ test('a hidden tiles layer drops its atlas; shown again it builds one at the vie const f = fixture(); const layer = f.imagery.createLayer({ id: 'a', source: tiles() }); layer.show(false); - assert.equal(f.diagnostics().a.atlas, null); + assert.equal(f.last().slots.length, 0); assert.equal(f.credits.get('NASA GIBS'), 0, 'its credit goes too'); f.changed.raiseEvent(); - assert.equal(f.diagnostics().a.atlas, null); + assert.equal(f.last().slots.length, 0); layer.show(true); await tick(); - assert.ok(f.diagnostics().a.atlas); + assert.ok(f.last().slots[0].texture.image.width > 0); assert.equal(f.last().slots.length, 1); }); @@ -215,8 +214,6 @@ test('geographic tiles draw in degrees through the source’s transform', async }), }); await tick(); - const { atlas } = f.diagnostics().radar; - assert.equal(atlas.level, 6, 'the source’s top level'); const [slot] = f.last().slots; assert.equal(slot.look[2], 0, 'sampled in degrees'); const [west, south, east, north] = slot.bounds; @@ -242,7 +239,6 @@ test('a staged tiles source builds its own atlas under the drawn one and takes i ); await tick(); assert.equal(f.last().slots[0].texture, drawn, 'the drawn atlas stays'); - assert.ok(f.diagnostics().a.staging, 'the staged atlas loads'); const next = f.jobs.slice(firstJobs); assert.ok(next.length > 0 && next.every((job) => /next/.test(job.url))); let done; diff --git a/src/globe/navara/imagery/pass.js b/src/globe/navara/imagery/pass.js index 4bc7d57d2..7fefca038 100644 --- a/src/globe/navara/imagery/pass.js +++ b/src/globe/navara/imagery/pass.js @@ -21,7 +21,7 @@ import { DataTexture, Matrix4, RGBADepthPacking, Uniform } from 'three'; import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js'; import { MAX_IMAGERY_SLOTS } from './index.js'; -export const IMAGERY_EFFECT_KEY = 'gevImagery'; +const IMAGERY_EFFECT_KEY = 'gevImagery'; const SLOTS = Array.from({ length: MAX_IMAGERY_SLOTS }, (_, index) => index); @@ -179,9 +179,6 @@ class ImageryEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) { // Navara takes the first anchor that exists: right after tone mapping. static insertAfter = ['toneMapping']; static insertBefore = [ - 'gevNepalEvent', - 'gevObservedWeather', - 'gevEarthquakeGround', 'gevGroundPulses', 'gevGround', 'gevPreSharpenOverlay', diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 00fa005ec..3730feafa 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -2,14 +2,11 @@ // the product way (viewOptions.js) and the GlobeEngine on it. Load it with a // dynamic import() so Navara stays out of the Cesium-mode bundle. -import { layerEffectDesc, withFrameUpdates } from './frameDescriptor.js'; import ThreeView, { Color as NavaraColor, MeshDesc, EffectDesc, Effect as NavaraEffect, - DrapedMesh, - setupMaterialForDrape, } from '@navaramap/three'; import * as geo from '../../geo/index.js'; import { DefaultPlugin } from '@navaramap/three-default-plugin'; @@ -124,7 +121,7 @@ function groundInView(engine) { * `?look=` (cinematic by default); `hd` draws at the device pixel ratio * with MSAA (Navara HD), else at CSS pixels without it. The sun follows the engine clock (the * page's app clock): the look reads its frame time. - * @returns {Promise<{ view: object, engine: import('../contract.js').GlobeEngine, destroy: () => void }>} + * @returns {Promise<{ setRenderLoop: (enabled: boolean) => void, engine: import('../contract.js').GlobeEngine, destroy: () => void }>} */ export async function createNavaraGlobe({ container, search, hd = false }) { if (!container) throw new TypeError('createNavaraGlobe needs a container'); @@ -272,10 +269,6 @@ export async function createNavaraGlobe({ container, search, hd = false }) { view, MeshDesc, NavaraColor, - EffectDesc: layerEffectDesc(EffectDesc), - NavaraEffect, - DrapedMesh, - setupMaterialForDrape, alignDaylightToRestoredView() { look.resetDaylight(); engine.render.request('restored-daylight'); @@ -283,20 +276,7 @@ export async function createNavaraGlobe({ container, search, hd = false }) { }), }); return { - view, engine, - NavaraColor, - MeshDesc, - EffectDesc, - LayerEffectDesc: layerEffectDesc(EffectDesc), - NavaraEffect, - DrapedMesh, - setupMaterialForDrape, - // Startup can fly from the engine's placeholder camera into a shared view. - alignDaylightToRestoredView() { - look.resetDaylight(); - engine.render.request('restored-daylight'); - }, setRenderLoop: (enabled) => { parked = !enabled; setRenderLoopEnabled(view, enabled); diff --git a/src/globe/navara/movers/batchIds.js b/src/globe/navara/movers/batchIds.js index 3461e1bd9..2ef86ebe7 100644 --- a/src/globe/navara/movers/batchIds.js +++ b/src/globe/navara/movers/batchIds.js @@ -34,7 +34,5 @@ export function createBatchIdPool() { released.add(wrapper); for (const id of wrapper.batchIds) if (id > 0) free.push(id); }, - /** The ids waiting for a wrapper. */ - size: () => free.length, }; } diff --git a/src/globe/navara/movers/batchIds.test.mjs b/src/globe/navara/movers/batchIds.test.mjs index 39390cc87..139f6e579 100644 --- a/src/globe/navara/movers/batchIds.test.mjs +++ b/src/globe/navara/movers/batchIds.test.mjs @@ -25,7 +25,6 @@ test('a rebuilt set takes the ids of the set it replaces', () => { set = wrapper(pool.source(ctx), 1000); } assert.equal(ctx.issued(), 1000); - assert.equal(pool.size(), 0); assert.equal(new Set(set.batchIds).size, 1000); }); @@ -35,7 +34,6 @@ test('live sets never share an id, and a set gives its ids back once', () => { const a = wrapper(pool.source(ctx), 3); pool.release(a); pool.release(a); - assert.equal(pool.size(), 3); const b = wrapper(pool.source(ctx), 2); const c = wrapper(pool.source(ctx), 2); const ids = [...b.batchIds, ...c.batchIds]; @@ -47,7 +45,6 @@ test('ids Navara could not give (0) are not pooled', () => { const pool = createBatchIdPool(); pool.release({ batchIds: [0, 0, 7] }); pool.release(null); - assert.equal(pool.size(), 1); const none = { genGlobalBatchId: () => undefined }; assert.equal(pool.source(none).genGlobalBatchId(), 7); assert.equal(pool.source(none).genGlobalBatchId(), undefined); diff --git a/src/layers/shared/render/navaraGroundOrder.test.mjs b/src/globe/navara/navaraGroundOrder.test.mjs similarity index 89% rename from src/layers/shared/render/navaraGroundOrder.test.mjs rename to src/globe/navara/navaraGroundOrder.test.mjs index 96424710f..78376eddb 100644 --- a/src/layers/shared/render/navaraGroundOrder.test.mjs +++ b/src/globe/navara/navaraGroundOrder.test.mjs @@ -22,19 +22,11 @@ async function declared(key, path, className) { }; } const specs = [ - await declared( - 'gevImagery', - '../../../globe/navara/imagery/pass.js', - 'ImageryEffectDesc', - ), - await declared( - 'gevGround', - '../../../globe/navara/ground/pass.js', - 'GroundEffectDesc', - ), + await declared('gevImagery', './imagery/pass.js', 'ImageryEffectDesc'), + await declared('gevGround', './ground/pass.js', 'GroundEffectDesc'), await declared( 'gevPreSharpenOverlay', - '../../../globe/navara/overlay.js', + './overlay.js', 'GevPreSharpenOverlayEffectDesc', ), ]; diff --git a/src/globe/navara/picking.js b/src/globe/navara/picking.js index 6eec08eba..d4ca747b5 100644 --- a/src/globe/navara/picking.js +++ b/src/globe/navara/picking.js @@ -8,8 +8,6 @@ import { createCpuPicker } from '../cpuPicks.js'; -export { distanceToSegment } from '../cpuPicks.js'; - const CLICK_TIMEOUT_MS = 250; // A GPU answer this recent belongs to the click being delivered. const EARLY_ANSWER_MS = 100; diff --git a/src/globe/navara/picking.test.mjs b/src/globe/navara/picking.test.mjs index 84ecda8aa..09bb0598e 100644 --- a/src/globe/navara/picking.test.mjs +++ b/src/globe/navara/picking.test.mjs @@ -2,7 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createMoverStore, normalizeMoverSetOptions } from '../movers.js'; import { normalizeLineOptions } from '../lines.js'; -import { createNavaraPicking, distanceToSegment } from './picking.js'; +import { createNavaraPicking } from './picking.js'; +import { distanceToSegment } from '../cpuPicks.js'; // A projector that maps x → screen x and y → screen y, z is the depth (the // distance from the camera at the origin along +z). diff --git a/src/globe/navara/surface.js b/src/globe/navara/surface.js index 956435a93..02f804494 100644 --- a/src/globe/navara/surface.js +++ b/src/globe/navara/surface.js @@ -46,7 +46,7 @@ export function surfaceRay(place) { } /** Height above the ellipsoid of a hit `distance` metres down a surfaceRay. */ -export const heightAlongRay = (ray, distance) => +export const heightAlongRay = (distance) => Number.isFinite(distance) ? RAY_START_HEIGHT - distance : undefined; const worldRay = new Ray(); @@ -279,7 +279,7 @@ export function createNavaraSurface(view, hooks) { shownRoots.length = 0; if (!Number.isFinite(hit.distance)) hit = finestHiddenHit(roots, excluded, ray); - const height = heightAlongRay(ray, hit.distance); + const height = heightAlongRay(hit.distance); if (height === undefined) return terrainDetail(place); return { height, source: 'tiles', tileSizeM: tileSizeOf(hit.root) }; }; diff --git a/src/globe/navara/surface.test.mjs b/src/globe/navara/surface.test.mjs index 7d900c723..cd69d16b1 100644 --- a/src/globe/navara/surface.test.mjs +++ b/src/globe/navara/surface.test.mjs @@ -84,8 +84,8 @@ test('surfaceRay runs down the ellipsoid normal from 12 km', () => { cartesian3.create(), ); near(cartographic.fromCartesian(below, WGS84).height, 0, 1e-6, 'ground'); - assert.equal(heightAlongRay(ray, 11950), 50); - assert.equal(heightAlongRay(ray, null), undefined); + assert.equal(heightAlongRay(11950), 50); + assert.equal(heightAlongRay(null), undefined); }); test('sampleHeight hits the top of the loaded tiles and keeps tile geometry', () => { diff --git a/src/hud.js b/src/hud.js index b214eb977..5d0091ba3 100644 --- a/src/hud.js +++ b/src/hud.js @@ -249,7 +249,6 @@ export class IntelHUD { this.basemapContext = basemapContext; this.placeSearch = placeSearch; this.engine = engine; - this.viewer = viewer; this._visible = false; this._autoMode = true; // auto show/hide based on style this._currentStyle = 'normal'; @@ -1236,7 +1235,6 @@ export class IntelHUD { this._removeCameraMoveStart = null; this._summaryRequest?.abort(); this.engine = engine ?? null; - this.viewer = viewer; if (!this.engine) return; this._removeCameraMoveStart = this.engine.camera.moveStart?.addEventListener(this._onCameraMoveStart); diff --git a/src/layers/aircraft/render/display.js b/src/layers/aircraft/render/display.js index 1bf20de38..550949d47 100644 --- a/src/layers/aircraft/render/display.js +++ b/src/layers/aircraft/render/display.js @@ -5,7 +5,7 @@ // engine.camera.follow (the follow camera), engine.projector and // engine.camera.pose() (the view the logic reads). The engine draws. // -// Handles keep the Cesium field vocabulary the logic writes (contract.js) as +// Handles expose the fields the logic writes (contract.js) as // setters that write the engine's instance stores; the writes of a frame are // committed once, at the end of the frame listener that made them (or in a // microtask for writes outside a frame), so a fleet tick draws in its frame. @@ -23,13 +23,9 @@ import { drawAircraftIconAtlas, } from '../../../data/aircraftIcons.js'; import { createEngineLayerView } from '../../shared/render/engineView.js'; -import { rgbaFromCss } from '../../shared/color.js'; +import { Rgba, rgbaFromCss } from '../../shared/color.js'; import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; -import { - createMoverBank, - StoredColor, - writeFields, -} from '../../shared/render/moverBank.js'; +import { createMoverBank, writeFields } from '../../shared/render/moverBank.js'; const BASE_SIZE_PX = 20; @@ -41,7 +37,7 @@ const BASE_SIZE_PX = 20; */ const GLTF_AXIS = [0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1]; // The trail's alpha where it passes the depth test and where the scene hides -// it (as data/trailRenderer.js draws track histories), and the live head's. +// it, and the live head's. const TRAIL_ALPHA = 0.85; const TRAIL_OCCLUDED_ALPHA = 0.4; const HEAD_ALPHA = 0.9; @@ -245,7 +241,12 @@ export function createAircraftDisplay( this._scale = spec.scale ?? 1; this._width = spec.width ?? BASE_SIZE_PX; this._height = spec.height ?? BASE_SIZE_PX; - this._color = new StoredColor(spec.color); + this._color = new Rgba( + spec.color?.red ?? 1, + spec.color?.green ?? 1, + spec.color?.blue ?? 1, + spec.color?.alpha ?? 1, + ); this._image = spec.image; this.cell = iconCells.get(spec.image) ?? iconCells.get(defaultIcon); this._scaleByDistance = spec.scaleByDistance @@ -354,7 +355,12 @@ export function createAircraftDisplay( (value?.alpha ?? 1) === this._color.alpha ) return; - this._color = new StoredColor(value); + this._color = new Rgba( + value?.red ?? 1, + value?.green ?? 1, + value?.blue ?? 1, + value?.alpha ?? 1, + ); rgbaOf(this._color); this.write(spriteColorField); } @@ -493,7 +499,12 @@ export function createAircraftDisplay( this.placed = false; this._show = true; this._scale = spec.scale ?? 1; - this._color = new StoredColor(spec.color); + this._color = new Rgba( + spec.color?.red ?? 1, + spec.color?.green ?? 1, + spec.color?.blue ?? 1, + spec.color?.alpha ?? 1, + ); this._id = spec.id; this.modelMatrix = geo.matrix4.create(); this.translation = geo.cartesian3.create(); @@ -562,7 +573,12 @@ export function createAircraftDisplay( } set color(value) { - this._color = new StoredColor(value); + this._color = new Rgba( + value?.red ?? 1, + value?.green ?? 1, + value?.blue ?? 1, + value?.alpha ?? 1, + ); this.gevBank?.update(this, { color: rgbaOf(this._color) }); } } diff --git a/src/layers/aircraft/tracking.js b/src/layers/aircraft/tracking.js index 550fe5c1a..637faacbb 100644 --- a/src/layers/aircraft/tracking.js +++ b/src/layers/aircraft/tracking.js @@ -545,12 +545,11 @@ export function createAircraftTracking({ } flightState._trail?.setVisible(!flightState._cockpitContactMode); // Live head segment: last DISPLAYED body point → current dead-reckoned icon, updated - // every frame via a CallbackProperty (Cesium updates entity-polyline positions cheaply, - // unlike the trail primitive which fully rebuilds on setPositions). Keeps the head glued - // to the 12 Hz icon instead of lagging ~1 s behind it. + // every frame through the display callback. Keeps the head glued + // to the 12 Hz icon while the trail body updates less often. if (!flightState._trailHeadEntity && flightState._renderer) { flightState._trailHeadEntity = flightState._renderer.trails.createHead({ - // 'gev-trail' namespace: claimed by trailRenderer's pick + // 'gev-trail' namespace: claimed by the display's pick // owner so a click on the head segment never reads as empty space. id: `gev-trail:${trailHeadIdPrefix}-head-${++flightState._trailHeadSeq}`, show: !flightState._cockpitContactMode, @@ -812,10 +811,8 @@ export function createAircraftTracking({ flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick // Stop tracking and remove the entity. skipViewerUntrack: when ANOTHER layer just grabbed the - // follow-camera, we tear down our own state but must NOT clear viewer.trackedEntity (the new owner - // controls it now — clearing it would yank the camera off their plane). Releasing trackedEntity - // does NOT move the camera: Cesium resets the lookAt transform in place, so the view stays where - // the follow left it and the user can immediately orbit/zoom. + // follow camera, tear down only our own state and keep the new owner. + // Releasing our follow keeps the camera pose so the user can orbit or zoom. if (flightState._renderer && !skipViewerUntrack) { flightState._renderer.follow.release(); } @@ -869,12 +866,12 @@ export function createAircraftTracking({ * Begin tracking a specific aircraft by ICAO24 address. * * Clears any existing tracked flight, hides its billboard, and creates a - * new Entity with: - * - A CallbackProperty position driven by dead-reckoning (_deadReckon). - * - A CallbackProperty rotation from the aircraft's course. + * tracked display handle with: + * - A position callback driven by dead-reckoning (_deadReckon). + * - A rotation callback from the aircraft's course. * - An explicit host presentation model (the layer's tracked card). * - * The viewer's trackedEntity is set to this entity so the camera follows it. + * The display starts the engine follow for this handle. * * @param {string} icao24 - ICAO 24-bit transponder address to track. */ @@ -980,10 +977,8 @@ export function createAircraftTracking({ trackedStyle.labelColor, ); - // A billboard has a ~zero bounding sphere, so Cesium's default follow distance is - // far too tight (the user had to scroll out to read the plane). Give the entity a - // calibrated viewFrom — behind + above, distance scaled to altitude — for a readable - // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) + // Frame the aircraft behind and above, with distance scaled to altitude. + // The offset uses ENU: east=+X, north=+Y, up=+Z. const followRange = Math.min( Math.max((followAltitudeM(info) || 1500) * 1.1 + 2500, 3000), 30000, @@ -994,10 +989,6 @@ export function createAircraftTracking({ z: followRange * 0.55, }); - // Cancel any in-progress camera flight first — otherwise Cesium won't apply the - // tracked entity's viewFrom on the first frame, so voice-initiated tracking (which - // often fires mid-fly_to_location) would follow the plane WITHOUT centering/framing it - // the way a click (idle camera) does. // Expose the camera's already-settled position to cross-module HUD consumers (the tracked-target // readout) so they draw at the SAME spot the camera framed, without recomputing the dead-reckon in // postRender (which would jitter the label against the now-stable plane). diff --git a/src/layers/flights/hiddenContactRotation.test.mjs b/src/layers/flights/hiddenContactRotation.test.mjs index d2354695f..76e529092 100644 --- a/src/layers/flights/hiddenContactRotation.test.mjs +++ b/src/layers/flights/hiddenContactRotation.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { createFlightState } from './state.js'; import { createRendering } from './rendering.js'; -import { horizonOccluder } from '../../data/iconOrientation.js'; +import { horizonVisible } from '../../globe/cameraMath.js'; import { FLEET_DR_INTERVAL_MS, HIDDEN_CONTACT_DR_INTERVAL_MS, @@ -93,7 +93,10 @@ function fleetFixture() { view: { camera, scene: {}, - horizon: () => horizonOccluder(camera), + horizon: () => ({ + isPointVisible: (position) => + horizonVisible(camera.positionWC, position), + }), frustumTester: () => () => true, }, }; diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 9b8537e18..08a6ba597 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -935,7 +935,7 @@ export function createQueries({ view.holdFrames(); setFocusEvidenceNowMs(startMs); flightState._lastFleetTickMs = startMs - FLEET_DR_INTERVAL_MS; - // Cross a browser task boundary, then close Cesium's pending-loop + // Cross a browser task boundary, then close the renderer's pending-loop // latch. Any already-queued callback still observes the false gate, // while manual evidence renders can begin without waiting on VSYNC. return new Promise((resolve) => diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js index 4e1d2eb7e..9f4bae5ee 100644 --- a/src/layers/flights/testing.js +++ b/src/layers/flights/testing.js @@ -1,4 +1,4 @@ -import { CYAN_TRANSPARENT, MODEL_HEADING_OFFSET_DEG } from './policy.js'; +import { CYAN_TRANSPARENT } from './policy.js'; import { CYAN } from '../shared/color.js'; export function createTesting({ @@ -46,9 +46,8 @@ export function createTesting({ * @param {object} state.meta * @param {object} state.billboard * @param {object} state.billboardCollection - * @param {object} state.viewer * @param {(options: object) => object} state.renderer The test's renderer - * over the viewer and collections (testSupport/aircraftViewerRenderer). + * with the view and collection fixtures for this test. * @param {Array} [state.history=[]] * @param {boolean} [state.tracked=true] * @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed @@ -61,19 +60,14 @@ export function createTesting({ meta, billboard, billboardCollection, - viewer, history = [], tracked = true, models = [], - modelCollection = null, renderer: createRenderer, }) { - // The test's renderer over its viewer and (fake) collections. + // The test's renderer over its fleet collection. flightState._renderer = createRenderer({ - viewer, billboardCollection, - modelCollection, - headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, }); flightState.enabled = flightState._renderer.fleet.visible(); flightState._billboards = new Map([[icao24, billboard]]); diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs index cbaf2681a..7780b3e98 100644 --- a/src/layers/military/ownership.test.mjs +++ b/src/layers/military/ownership.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { createMilitaryFlightLayer } from './index.js'; import { createFlightState } from './state.js'; import { createTracking } from './tracking.js'; -import { aircraftViewerRenderer } from '../../testSupport/aircraftViewerRenderer.mjs'; +import { recordingAircraftRenderer } from '../../testSupport/aircraftRecordingRenderer.mjs'; function services() { const names = [ @@ -183,7 +183,13 @@ async function firstPlacement(appClockLagMs) { }); const added = []; layer.testing._setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => + recordingAircraftRenderer(null, { + fleet: { + ...billboardCollection, + shown: billboardCollection?.show !== false, + }, + }), icao24: 'seed00', entity: null, meta: { rawLat: 0, rawLon: 0, onGround: false }, @@ -287,7 +293,13 @@ test('a vanished military contact fades while position-less rows keep theirs', a }); const viewer = { camera: { positionCartographic: null }, scene: {} }; layer.testing._setTrackedMilitaryRefreshStateForTest({ - renderer: aircraftViewerRenderer, + renderer: ({ billboardCollection }) => + recordingAircraftRenderer(null, { + fleet: { + ...billboardCollection, + shown: billboardCollection?.show !== false, + }, + }), icao24: 'seed00', entity: null, meta: { rawLat: 0, rawLon: 0, onGround: false }, diff --git a/src/layers/military/testing.js b/src/layers/military/testing.js index 7d242b721..5344dd57e 100644 --- a/src/layers/military/testing.js +++ b/src/layers/military/testing.js @@ -1,8 +1,4 @@ -import { - AMBER_TRANSPARENT, - MODEL_HEADING_OFFSET_DEG, - TRACKED_ICON_COLOR, -} from './policy.js'; +import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js'; export function createTesting({ flightState, @@ -21,9 +17,8 @@ export function createTesting({ * @param {object} state.meta * @param {object} state.billboard * @param {object} state.billboardCollection - * @param {object} state.viewer * @param {(options: object) => object} state.renderer The test's renderer - * over the viewer and collections (testSupport/aircraftViewerRenderer). + * with the view and collection fixtures for this test. * @param {Array} [state.history=[]] * @param {boolean} [state.tracked=true] * @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed @@ -36,20 +31,15 @@ export function createTesting({ meta, billboard, billboardCollection, - viewer, history = [], tracked = true, models = [], - modelCollection = null, renderer: createRenderer, }) { flightState.enabled = billboardCollection?.show !== false; - // The test's renderer over its viewer and (fake) collections. + // The test's renderer over its fleet collection. flightState._renderer = createRenderer({ - viewer, billboardCollection, - modelCollection, - headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, }); flightState._billboards = new Map([[icao24, billboard]]); flightState._cullPositions.clear(); diff --git a/src/layers/shared/render/groundRing.js b/src/layers/shared/render/groundRing.js deleted file mode 100644 index 9fdf323db..000000000 --- a/src/layers/shared/render/groundRing.js +++ /dev/null @@ -1,17 +0,0 @@ -// A geographic ring is local, including across 180°. Put the click and ring in -// the same longitude interval before casting the ray. -export function containsGroundPoint(ring, lon, lat) { - const origin = ring[0][0]; - const local = (x) => x - origin - 360 * Math.round((x - origin) / 360); - const x = local(lon); - let inside = false; - for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { - const [ax, ay] = ring[j], - [bx, by] = ring[i]; - if (ay > lat === by > lat) continue; - const a = local(ax), - b = local(bx); - if (x < a + ((b - a) * (lat - ay)) / (by - ay)) inside = !inside; - } - return inside; -} diff --git a/src/layers/shared/render/moverBank.js b/src/layers/shared/render/moverBank.js index 275683cb0..91ec5038b 100644 --- a/src/layers/shared/render/moverBank.js +++ b/src/layers/shared/render/moverBank.js @@ -112,17 +112,3 @@ export function createMoverBank(engine, { options, write, onCommit }) { }; return bank; } - -/** Plain colour copy (the stored value a handle's colour getter returns). */ -export class StoredColor { - constructor(color) { - this.red = color?.red ?? 1; - this.green = color?.green ?? 1; - this.blue = color?.blue ?? 1; - this.alpha = color?.alpha ?? 1; - } - - withAlpha(alpha) { - return new StoredColor({ ...this, alpha }); - } -} diff --git a/src/layers/shared/render/navaraGroundPicks.js b/src/layers/shared/render/navaraGroundPicks.js deleted file mode 100644 index 94d9bc507..000000000 --- a/src/layers/shared/render/navaraGroundPicks.js +++ /dev/null @@ -1,119 +0,0 @@ -import * as geo from '../../../geo/index.js'; -import { distanceToSegment } from '../../../globe/cpuPicks.js'; -import { containsGroundPoint } from './groundRing.js'; - -function groundScreen(engine, [lon, lat]) { - const point = geo.cartographic.fromDegrees(lon, lat); - const height = engine.surface.terrainHeight(point) ?? 0; - const position = geo.cartesian3.fromDegrees(lon, lat, height); - return ( - engine.projector.horizonVisible(position) && - engine.projector.worldToWindow(position, {}) - ); -} - -// The GPU may return the preceding gesture's vector feature. Resolve against -// the current gesture and let the engine arbitrate the result with sibling layers. -export function bindGroundShapePicks(engine, owner, shapes) { - const regions = []; - for (const { id, geometry, style } of shapes) { - const coords = - geometry.type === 'point' - ? [geometry.position] - : geometry.type === 'line' - ? geometry.positions - : geometry.outer; - const west = coords[0][0]; - const local = (lon) => lon - west - 360 * Math.round((lon - west) / 360); - let south = 90, - north = -90, - min = Infinity, - max = -Infinity; - for (const [lon, lat] of coords) { - south = Math.min(south, lat); - north = Math.max(north, lat); - min = Math.min(min, local(lon)); - max = Math.max(max, local(lon)); - } - regions.push({ - geometry, - style, - coords, - south, - north, - min, - max, - local, - id, - }); - } - const pick = (at) => { - const world = engine.picker.pickGlobe(at); - if (!world) return []; - const carto = geo.cartographic.fromCartesian(world); - const lon = geo.math.toDegrees(carto.longitude), - lat = geo.math.toDegrees(carto.latitude); - const depth = geo.cartesian3.distance(world, engine.camera.livePosition); - const pose = engine.camera.pose(); - // Conservative geographic margin for a displaced terrain pixel/marker at this zoom. - const margin = - (2 * Math.tan(pose.fovy / 2) * depth * 32) / - Math.max(1, engine.host.size().height) / - 110000; - const lonMargin = margin / Math.max(0.00001, Math.cos(carto.latitude)); - const hits = []; - for (const region of regions) { - const x = region.local(lon); - if ( - lat < region.south - margin || - lat > region.north + margin || - (region.max - region.min < 180 && - (x < region.min - lonMargin || x > region.max + lonMargin)) - ) - continue; - const { geometry, style, coords } = region; - let hit = false; - if (geometry.type === 'point') { - const screen = groundScreen(engine, geometry.position); - const size = style.pointRadius ?? 7; - hit = - screen && - Math.abs(at.x - screen.x) <= size && - Math.abs(at.y - screen.y) <= size; - } else { - if (geometry.type === 'polygon' && style.fill !== false) - hit = - containsGroundPoint(coords, lon, lat) && - !geometry.holes.some((ring) => containsGroundPoint(ring, lon, lat)); - if (!hit && (geometry.type === 'line' || style.outline !== false)) { - let last = - geometry.type === 'polygon' - ? groundScreen(engine, coords.at(-1)) - : null; - for (const coord of coords) { - const next = groundScreen(engine, coord); - if ( - last && - next && - distanceToSegment(at, last, next) <= - Math.max(2, Math.min(8, style.lineWidth ?? 2) / 2) - ) { - hit = true; - break; - } - last = next; - } - } - } - if (hit) - hits.push({ - onTop: 0, - order: geometry.type === 'point' ? 1 : 0, - depth, - result: { id: region.id, owner }, - }); - } - return hits; - }; - return engine.picker.registerMeshPicker(pick); -} diff --git a/src/layers/shared/render/navaraMoverBank.js b/src/layers/shared/render/navaraMoverBank.js deleted file mode 100644 index 023b34c0d..000000000 --- a/src/layers/shared/render/navaraMoverBank.js +++ /dev/null @@ -1,2 +0,0 @@ -// The mover bank under the name the Navara renderers import it by. -export { createMoverBank, StoredColor, writeFields } from './moverBank.js'; diff --git a/src/layers/shared/render/navaraPolygons.js b/src/layers/shared/render/navaraPolygons.js deleted file mode 100644 index aa91e6fa4..000000000 --- a/src/layers/shared/render/navaraPolygons.js +++ /dev/null @@ -1,65 +0,0 @@ -/** GeoJSON features grouped by paint. Swap complete batches so a failed - * source or layer allocation cannot wipe the last rendered result. - * - * Hidden means no layers at all: Navara 0.2.1 sometimes drops the first - * `layer.update({ show: false })` after a layer is added (a zone turned off - * right after an engine switch stayed drawn), while a deleted layer always - * goes. Showing again rebuilds the batches from the last groups. */ -export function createNavaraPolygonDisplay(engine, view, style) { - let visible = false; - let groups = new Map(); - let batches = []; - function release(items) { - for (const { layer, source } of items) { - layer?.delete(); - source.delete(); - } - } - function build(next) { - const built = []; - try { - for (const [css, features] of next) { - const color = Number.parseInt(css.slice(1), 16); - const source = view.addSource({ - type: 'geojson', - data: { type: 'FeatureCollection', features }, - }); - const batch = { source }; - built.push(batch); - batch.layer = view.addLayer({ - type: 'vector', - source, - ...style(color), - }); - } - } catch (error) { - release(built); - throw error; - } - release(batches); - batches = built; - } - return { - setVisible(show) { - if (visible === show) return; - if (show) build(groups); - else { - release(batches); - batches = []; - } - visible = show; - engine.render.request('polygon-visibility'); - }, - replace(next) { - if (visible) build(next); - groups = next; - engine.render.request('polygon-data'); - }, - destroy() { - release(batches); - batches = []; - groups = new Map(); - engine.render.request('polygon-destroy'); - }, - }; -} diff --git a/src/layers/shared/render/navaraPolygons.test.mjs b/src/layers/shared/render/navaraPolygons.test.mjs deleted file mode 100644 index 6557fb1bc..000000000 --- a/src/layers/shared/render/navaraPolygons.test.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createNavaraPolygonDisplay } from './navaraPolygons.js'; - -function fakeView({ failOn = null } = {}) { - const live = new Set(); - let added = 0; - const handle = (kind, description) => { - const item = { - kind, - description, - delete: () => live.delete(item), - update: () => assert.fail('the display never updates a layer in place'), - }; - live.add(item); - return item; - }; - return { - live, - layers: () => [...live].filter((item) => item.kind === 'layer'), - addSource: (description) => handle('source', description), - addLayer: (description) => { - if (++added === failOn) throw new Error('layer allocation failed'); - return handle('layer', description); - }, - }; -} - -const engine = { render: { request() {} } }; -const groups = (...colors) => - new Map(colors.map((css) => [css, [{ type: 'Feature', id: css }]])); - -test('a hidden display holds no Navara layers, and showing it rebuilds them', () => { - const view = fakeView(); - const display = createNavaraPolygonDisplay(engine, view, (color) => ({ - polygon: { color }, - })); - display.replace(groups('#ff0000', '#00ff00')); - assert.equal(view.live.size, 0, 'nothing is built while hidden'); - display.setVisible(true); - assert.deepEqual( - view.layers().map((layer) => layer.description.polygon.color), - [0xff0000, 0x00ff00], - ); - // Navara 0.2.1 may drop a show:false update on a new layer: hiding deletes. - display.setVisible(false); - assert.equal(view.live.size, 0); - display.setVisible(true); - assert.equal(view.layers().length, 2); - display.destroy(); - assert.equal(view.live.size, 0); -}); - -test('a visible display swaps whole batches and keeps the last on a failure', () => { - const view = fakeView({ failOn: 3 }); - const display = createNavaraPolygonDisplay(engine, view, (color) => ({ - polygon: { color }, - })); - display.setVisible(true); - display.replace(groups('#ff0000')); - display.replace(groups('#0000ff')); - assert.deepEqual( - view.layers().map((layer) => layer.description.polygon.color), - [0x0000ff], - ); - assert.throws( - () => display.replace(groups('#00ff00')), - /layer allocation failed/, - ); - assert.deepEqual( - view.layers().map((layer) => layer.description.polygon.color), - [0x0000ff], - ); - assert.equal(view.live.size, 2, 'the failed batch released its source'); - // The failed rows were not adopted: hiding and showing redraws the last. - display.setVisible(false); - display.setVisible(true); - assert.deepEqual( - view.layers().map((layer) => layer.description.polygon.color), - [0x0000ff], - ); -}); diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index a998b3e49..80b52bec7 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -2,9 +2,21 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createVesselLayer } from './index.js'; import { createVesselState } from './state.js'; -import { vesselViewerRenderer } from '../../testSupport/vesselViewerRenderer.mjs'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { createVesselsDisplay, vesselAtlasLayout } from './render/display.js'; const noop = () => {}; +function vesselDisplay() { + const layout = vesselAtlasLayout(); + const renderer = createVesselsDisplay(createFakeEngine(), { + drawAtlas: async () => ({ + canvas: { width: layout.width, height: layout.height }, + cells: layout.cells, + }), + }); + renderer.fleet.attach({ show: true }); + return renderer; +} function services() { return { context: { @@ -28,7 +40,7 @@ function services() { }, worldFocus: {}, renderer: { - create: ({ viewer }) => vesselViewerRenderer({ viewer }), + create: () => vesselDisplay(), }, render: { holdContinuousRender: noop, @@ -42,14 +54,20 @@ function setup(source, options = {}) { const primitives = new Set(); layer.testing._setVesselStateForTest({ viewer: {}, - renderer: vesselViewerRenderer, - billboardCollection: { - add(options) { - const primitive = { ...options }; - primitives.add(primitive); - return primitive; - }, - remove: (primitive) => primitives.delete(primitive), + renderer: () => { + const display = vesselDisplay(); + const add = display.fleet.add; + const remove = display.fleet.remove; + display.fleet.add = (spec) => { + const handle = add(spec); + primitives.add(handle); + return handle; + }; + display.fleet.remove = (handle) => { + primitives.delete(handle); + remove(handle); + }; + return display; }, }); let now = 1000; diff --git a/src/layers/vessels/render/display.js b/src/layers/vessels/render/display.js index 71b71b1f3..6d66a4ce0 100644 --- a/src/layers/vessels/render/display.js +++ b/src/layers/vessels/render/display.js @@ -13,9 +13,9 @@ // writes outside a frame). import * as geo from '../../../geo/index.js'; -import { createMoverBank, StoredColor } from '../../shared/render/moverBank.js'; +import { createMoverBank } from '../../shared/render/moverBank.js'; import { createEngineLayerView } from '../../shared/render/engineView.js'; -import { rgbaFromCss } from '../../shared/color.js'; +import { Rgba, rgbaFromCss } from '../../shared/color.js'; import { decodeImages, imageAtlasLayout, @@ -208,7 +208,7 @@ export function createVesselsDisplay( storedRotation: spec.rotation ?? 0, storedScale: spec.scale ?? 1, storedImage: spec.image, - storedColor: new StoredColor(), + storedColor: new Rgba(), // The chevron artwork's size, before the scale. width: VESSEL_ICON_PX, height: VESSEL_ICON_PX, @@ -255,7 +255,12 @@ export function createVesselsDisplay( return handle.storedColor; }, set color(value) { - handle.storedColor = new StoredColor(value); + handle.storedColor = new Rgba( + value?.red ?? 1, + value?.green ?? 1, + value?.blue ?? 1, + value?.alpha ?? 1, + ); scratchRgba[0] = handle.storedColor.red; scratchRgba[1] = handle.storedColor.green; scratchRgba[2] = handle.storedColor.blue; diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index 2d971753a..8189d1a06 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -49,8 +49,7 @@ export function createTesting({ .map((record) => [record.mmsi, record]), ); state.selectedRecord = options.selectedRecord || null; - // The test's renderer over its viewer and collection - // (testSupport/vesselViewerRenderer), else the layer's for `engine`. + // The test's renderer, else the layer's display for `engine`. state.renderer = typeof options.renderer === 'function' ? options.renderer({ diff --git a/src/testSupport/aircraftRecordingRenderer.mjs b/src/testSupport/aircraftRecordingRenderer.mjs index b4710cff2..7ee7e7a8b 100644 --- a/src/testSupport/aircraftRecordingRenderer.mjs +++ b/src/testSupport/aircraftRecordingRenderer.mjs @@ -5,7 +5,7 @@ import * as geo from '../geo/index.js'; /** A renderer of plain records, for the logic alone. */ -export function recordingAircraftRenderer(engine) { +export function recordingAircraftRenderer(engine, ports = {}) { const calls = []; const sprites = new Set(); const camera = { @@ -118,5 +118,7 @@ export function recordingAircraftRenderer(engine) { surfaceExclusions: () => [], destroy: () => calls.push(['destroy']), }; + for (const [name, values] of Object.entries(ports)) + Object.assign(renderer[name], values); return renderer; } diff --git a/src/testSupport/aircraftViewerRenderer.mjs b/src/testSupport/aircraftViewerRenderer.mjs deleted file mode 100644 index 69acc474d..000000000 --- a/src/testSupport/aircraftViewerRenderer.mjs +++ /dev/null @@ -1,258 +0,0 @@ -// An aircraft renderer (src/layers/aircraft/render/contract.js) over a stub -// viewer and the test's own collections, for testing an aircraft layer's -// logic against Cesium-shaped fakes: sprites are what the test's -// billboardCollection.add returns, models load through -// Cesium.Model.fromGltfAsync (which tests stub to control readiness and -// failures) into its modelCollection, the tracked aircraft is an Entity in -// viewer.entities followed through viewer.trackedEntity (with Cesium's one -// exact tracked frame), trails are entity polylines. Not the application's display (render/display.js): that one is -// tested in display.test.mjs. - -import * as Cesium from 'cesium'; -import { createTrail } from '../data/trailRenderer.js'; -import { horizonOccluder } from '../data/iconOrientation.js'; -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; -import { applyTrackedCameraFrame } from '../globe/cesium/follow.js'; - -const cesiumColor = (color) => - color instanceof Cesium.Color - ? color - : new Cesium.Color(color.red, color.green, color.blue, color.alpha); - -const cesiumNearFar = (scalar) => - scalar instanceof Cesium.NearFarScalar - ? scalar - : new Cesium.NearFarScalar( - scalar.near, - scalar.nearValue, - scalar.far, - scalar.farValue, - ); - -/** - * @param {{ viewer: object, billboardCollection?: object, - * modelCollection?: object, engine?: object, headingOffsetDeg?: number }} options - * `engine`: what the layer's selection dispatcher binds to (default: the - * engine registered for the viewer, if any). - */ -export function aircraftViewerRenderer({ - viewer, - billboardCollection = null, - modelCollection = null, - engine = null, - headingOffsetDeg = 0, -}) { - const billboards = billboardCollection; - const models = modelCollection; - const scratchHpr = new Cesium.HeadingPitchRoll(0, 0, 0); - const scratchSphere = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 1.0); - let cullingVolume = null; - const inFrustum = (position) => { - Cesium.Cartesian3.clone(position, scratchSphere.center); - return ( - cullingVolume.computeVisibility(scratchSphere) !== - Cesium.Intersect.OUTSIDE - ); - }; - const scratchSize = {}; - const load = (spec) => - Cesium.Model.fromGltfAsync({ - url: spec.url, - asynchronous: false, - minimumPixelSize: spec.minimumPixelSize, - scale: spec.scale, - color: spec.color, - colorBlendMode: Cesium.ColorBlendMode.MIX, - colorBlendAmount: spec.blendAmount, - id: spec.id, - }); - - const view = { - get camera() { - return viewer?.camera; - }, - get scene() { - return viewer?.scene; - }, - // The engine registered for the viewer (the surface samplers take an - // engine). - get surfaceOwner() { - return engine ?? engineFromViewer(viewer); - }, - get surfaceScene() { - return engine ?? engineFromViewer(viewer); - }, - frameNumber: () => viewer?.scene?.frameState?.frameNumber ?? -1, - viewportHeightPx: () => viewer.scene.canvas.clientHeight, - frustumTester() { - const camera = viewer.camera; - cullingVolume = camera.frustum.computeCullingVolume( - camera.positionWC, - camera.directionWC, - camera.upWC, - ); - return inFrustum; - }, - horizon: () => horizonOccluder(viewer.camera), - worldToWindow: (position, result) => - Cesium.SceneTransforms.worldToWindowCoordinates( - viewer.scene, - position, - result, - ), - requestRender: () => viewer?.scene?.requestRender?.(), - on(event, listener) { - const source = - event === 'moveEnd' - ? viewer?.camera?.moveEnd - : event === 'preRender' - ? viewer?.scene?.preRender - : event === 'preUpdate' - ? viewer?.scene?.preUpdate - : null; - return source ? source.addEventListener(listener) : null; - }, - isDestroyed: () => !viewer || viewer.isDestroyed?.() === true, - holdFrames() {}, - closeFrameLatch() {}, - releaseFrames() {}, - }; - - const renderer = { - get engine() { - return engine ?? engineFromViewer(viewer); - }, - view, - fleet: { - attach() {}, - detach() {}, - attached: () => !!billboards, - visible: () => !!billboards && billboards.show, - setVisible(visible) { - if (billboards) billboards.show = visible; - }, - add: (spec) => billboards.add(spec), - remove: (sprite) => billboards.remove(sprite), - restoreOrder() {}, - restoreOrderOnEnable() {}, - }, - models: { - live: () => !!models && !models.isDestroyed(), - setVisible(visible) { - if (models) models.show = visible; - }, - loadFleet: load, - loadTracked: load, - preload: (url) => - Cesium.Model.fromGltfAsync({ url, asynchronous: false }), - add: (model) => models.add(model), - remove(model) { - try { - models?.remove(model); - } catch { - /* gone */ - } - }, - destroy(model) { - try { - model.destroy(); - } catch { - /* gone */ - } - }, - setPose(model, position, headingDeg, offsetDeg = headingOffsetDeg) { - scratchHpr.heading = Cesium.Math.toRadians( - (headingDeg || 0) + offsetDeg, - ); - return Cesium.Transforms.headingPitchRollToFixedFrame( - position, - scratchHpr, - Cesium.Ellipsoid.WGS84, - undefined, - model.modelMatrix, - ); - }, - }, - tracked: { - create: (spec) => - viewer.entities.add({ - position: new Cesium.CallbackProperty(() => spec.position(), false), - trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, - billboard: { - image: spec.image, - width: spec.width, - height: spec.height, - scale: spec.scale, - color: new Cesium.CallbackProperty( - () => cesiumColor(spec.color()), - false, - ), - scaleByDistance: cesiumNearFar(spec.scaleByDistance), - alignedAxis: Cesium.Cartesian3.ZERO, - disableDepthTestDistance: spec.disableDepthTestDistance, - rotation: new Cesium.CallbackProperty(() => spec.rotation(), false), - }, - }), - remove: (handle) => viewer.entities.remove(handle), - isLive: (handle) => !!viewer?.entities?.contains?.(handle), - setImage(handle, image) { - if (handle?.billboard) handle.billboard.image = image; - }, - setViewFrom(handle, offset) { - handle.viewFrom = new Cesium.Cartesian3(offset.x, offset.y, offset.z); - }, - billboardSize(handle) { - const billboard = handle?.billboard; - if (!billboard) return null; - const time = viewer.clock?.currentTime; - scratchSize.width = billboard.width?.getValue(time); - scratchSize.height = billboard.height?.getValue(time); - scratchSize.scale = billboard.scale?.getValue(time); - scratchSize.scaleByDistance = billboard.scaleByDistance?.getValue(time); - return scratchSize; - }, - }, - follow: { - // The one exact tracked frame, as Cesium's engine follow applies it. - start(handle) { - viewer.camera?.cancelFlight?.(); - viewer.trackedEntity = handle; - return applyTrackedCameraFrame(viewer, handle, handle.viewFrom) || null; - }, - refocus: (handle) => renderer.follow.start(handle), - release() { - if (viewer) viewer.trackedEntity = undefined; - }, - isFollowing: (handle) => !!viewer && viewer.trackedEntity === handle, - current: () => viewer?.trackedEntity, - onChanged: (listener) => - viewer.trackedEntityChanged?.addEventListener(listener) ?? (() => {}), - }, - trails: { - create: ({ color, width }) => createTrail(viewer, { color, width }), - createHead({ id, show, positions, color, width }) { - if (!viewer?.entities) return null; - return viewer.entities.add({ - id, - show, - polyline: { - positions: new Cesium.CallbackProperty(() => positions(), false), - width, - material: Cesium.Color.fromCssColorString(color).withAlpha(0.9), - }, - }); - }, - removeHead(head) { - try { - if (head) viewer?.entities?.remove(head); - } catch { - /* already gone */ - } - }, - }, - surfaceExclusions: ({ sprites = [], models: modelHandles = [] }) => - [...sprites, ...modelHandles].filter(Boolean), - destroy() {}, - }; - return renderer; -} diff --git a/src/testSupport/vesselViewerRenderer.mjs b/src/testSupport/vesselViewerRenderer.mjs deleted file mode 100644 index a1860c41a..000000000 --- a/src/testSupport/vesselViewerRenderer.mjs +++ /dev/null @@ -1,112 +0,0 @@ -// A vessel renderer (src/layers/vessels/render/contract.js) over a stub -// viewer and the test's own billboard collection, for testing the vessel -// layer's logic against Cesium-shaped fakes: chevrons are what the -// collection's add returns (the record is their id), the camera reads are -// the viewer's (Cesium's horizon occluder and window projection, which tests -// may stub), "another layer took the camera" is viewer.trackedEntityChanged -// and trails come from `createTrail` (none without it). Not the application's -// display (render/display.js): that one is tested in display.test.mjs. - -import * as Cesium from 'cesium'; - -/** - * @param {{ viewer: object, billboardCollection?: object, - * createTrail?: (viewer: object, options: object) => object }} options - */ -export function vesselViewerRenderer({ - viewer, - billboardCollection = null, - createTrail = null, -}) { - let collection = billboardCollection; - - const view = { - get camera() { - return viewer?.camera; - }, - get scene() { - return viewer?.scene; - }, - horizon() { - const cameraPosition = viewer?.camera?.positionWC; - if (!cameraPosition) return null; - return new Cesium.EllipsoidalOccluder( - Cesium.Ellipsoid.WGS84, - cameraPosition, - ); - }, - worldToWindow: (position, result) => - Cesium.SceneTransforms.worldToWindowCoordinates( - viewer.scene, - position, - result, - ), - canvasSize() { - const canvas = viewer?.scene?.canvas || viewer?.canvas; - return { - width: Number(canvas?.clientWidth) || 0, - height: Number(canvas?.clientHeight) || 0, - }; - }, - requestRender: () => viewer?.scene?.requestRender?.(), - on(event, listener) { - const source = - event === 'preRender' - ? viewer?.scene?.preRender - : event === 'moveEnd' - ? viewer?.camera?.moveEnd - : null; - return typeof source?.addEventListener === 'function' - ? source.addEventListener(listener) - : null; - }, - }; - - const fleet = { - attached: () => Boolean(collection), - attach({ show }) { - if (collection || !viewer) return; - collection = new Cesium.BillboardCollection(); - collection.show = show; - viewer.scene.primitives.add(collection); - }, - setVisible(show) { - if (collection) collection.show = show; - }, - add: ({ position, show, image, scale, rotation, id }) => - collection.add({ position, show, image, scale, rotation, id }), - remove(handle) { - collection?.remove(handle); - }, - detach() { - if (collection && viewer?.scene?.primitives) - viewer.scene.primitives.remove(collection); - collection = null; - }, - }; - - return { - engine: null, - view, - fleet, - // The billboard id is the record. - pickedRecord(picked) { - const rawId = picked?.id ?? picked?.primitive?.id; - return rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi') - ? rawId - : null; - }, - follow: { - onChanged: (listener) => - viewer.trackedEntityChanged.addEventListener(listener), - active: () => Boolean(viewer?.trackedEntity), - }, - trails: { - create: ({ color, width }) => - typeof createTrail === 'function' && viewer - ? createTrail(viewer, { color, width }) - : null, - }, - destroy() {}, - }; -} diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index 58c456db0..c5867101d 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -88,8 +88,8 @@ for (const [name, files, pattern] of [ /cesium imports belong in src\/globe\/cesium\//, ], [ - 'W1 shared trailRenderer keeps SDK imports in the engine', - { 'src/data/trailRenderer.js': "import 'cesium';" }, + 'Shared render modules keep SDK imports in the engine', + { 'src/layers/shared/render/moverBank.js': "import 'cesium';" }, /cesium imports belong in src\/globe\/cesium\//, ], [ @@ -360,21 +360,18 @@ test('legacy renderer names grant no SDK import exemption', (t) => { assert.throws(() => checkImportDirections(root), /three imports belong/); }); -test('remaining Navara layer helpers still load through the manifest or mode', (t) => { +test('Navara flow helpers load dynamically through the manifest', (t) => { const { root, write } = fixture(t); - write('src/layers/shared/render/navaraPolygons.js'); + write('src/globe/navara/windFlow.js'); write( 'src/app/layers/renderers.js', - "export const load = () => import('../../layers/shared/render/navaraPolygons.js');", + "export const load = () => import('../../globe/navara/windFlow.js');", ); assert.doesNotThrow(() => checkImportDirections(root)); - write( - 'src/app/data.js', - "import '../layers/shared/render/navaraPolygons.js';", - ); + write('src/app/data.js', "import '../globe/navara/windFlow.js';"); assert.throws( () => checkImportDirections(root), - /A Navara layer renderer loads only through/, + /The Navara engine loads only through/, ); }); diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 68291f6fe..012dcfff4 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -352,8 +352,7 @@ export class LocationNavigation { this._stopOrbit(); this.cockpitView?.exit({ restoreTracking: false }); try { - // A layer an engine leaves out (military awareness on Navara) has no - // controls: release the trackers it would release, below. + // If awareness controls are unavailable, release the trackers directly. if (typeof militaryAwarenessLayer?.releaseCameraOwnership !== 'function') throw new Error('no military awareness controls'); militaryAwarenessLayer.releaseCameraOwnership?.({ origin: 'tool' }); diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 53ac78426..f1650127a 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -146,8 +146,7 @@ export class NavigationController { } = this.tracking; let contactSelected = false; try { - // A layer an engine leaves out (military awareness on Navara) has no - // controls: release the trackers it would release, below. + // If awareness controls are unavailable, release the trackers directly. if (typeof militaryAwarenessLayer?.releaseCameraOwnership !== 'function') throw new Error('no military awareness controls'); contactSelected = Boolean(