diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 49b375740..dd2d6be8c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -183,8 +183,10 @@ 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 Cesium passed 66 of 66 and - Navara 69 of 69. `movers` and `lines` are required of every engine: Cesium + 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 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 @@ -202,6 +204,26 @@ one application, and only the globe-bound parts move to the next engine. perimeters have prototypes in `render/ground.js`. - On November 7, 2026, `scripts/qa-ground-suite.mjs` (the ground checks with pixels) passed 3 of 3 on both engines. +- **Rasters on the ground** (`engine.imagery`) are required of every engine + too: layers of Web Mercator tiles from a URL template or of one image (a + canvas, an image, RGBA bytes or an image URL) over a rectangle, each with + its alpha, show, split side and draw `order`, and one split position per + engine (`docs/GLOBE-PRIMITIVES.md`). + - Cesium draws each as an `ImageryLayer` on the imagery stack of the + surface shown (the globe, or the shown 3D tileset's), and follows it + (`src/globe/cesium/imagery.js`). + - Navara draws them in one screen pass after tone mapping, classified on + the scene depth (`src/globe/navara/imagery/`). + - Active Fires' far-zoom heatmap and Recent Imagery draw through it; + weather, wind and the Bhote Koshi event imagery do not yet. + - `engine.maps.addOverlay` (Navara's native raster layers for Sea Marks + and Ski Pistes) stays until those overlays move to `engine.imagery`. + - 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). - **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 @@ -233,11 +255,9 @@ one application, and only the globe-bound parts move to the next engine. `src/layers/awareness/render/pulseDisc.js` (`render/cesiumPulses.js`, a material disc, on Cesium; `render/navaraPulses.js`, a surface pass that the Navara mode loads and passes in). - The Active Fires far-zoom heatmap, a raster draped on the ground that the - contract has no primitive for yet, draws behind one seam, - `src/layers/firms/render/heatField.js` (`render/cesiumHeat.js`, an imagery - layer, on Cesium; `render/navaraHeat.js`, a surface pass that the Navara - mode loads and passes in). CCTV's monitor plane and viewshed volume, which the + The Active Fires far-zoom heatmap is an `engine.imagery` layer, and Recent + Imagery draws its days through `engine.imagery` from its own + `render/display.js`. CCTV's monitor plane and viewshed volume, which the contract has no primitive for yet, draw behind one seam, `src/layers/cctv/render/meshes.js` (`render/cesiumMeshes.js` on Cesium; `render/navaraMeshes.js`, which the Navara mode loads and passes in). Cesium diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index 9970c4ea3..24c734180 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -1,13 +1,14 @@ # Globe primitives A layer draws through the globe-engine contract (`src/globe/contract.js`), -not through Cesium or Navara. The contract has three drawing primitives: +not through Cesium or Navara. The contract has four drawing primitives: - `engine.movers`: things at points; - `engine.lines`: lines in the air; -- `engine.ground`: fills on the ground. +- `engine.ground`: fills on the ground; +- `engine.imagery`: rasters on the ground. -Each engine implements all three. Cesium's are in `src/globe/cesium/`, +Each engine implements all four. Cesium's are in `src/globe/cesium/`, Navara's in `src/globe/navara/`, and the fake engine's in `src/globe/testing/fakeEngine.js`. The checks every engine must pass are in `src/globe/testing/contractSuite.mjs`. @@ -211,6 +212,118 @@ take 135 ms. A layer with more lines than that (Street Level's up to 14 400 sequences) needs a set-wide grid of bins first, as areas have, before it moves to `engine.ground`. +## Imagery: rasters on the ground + +`engine.imagery.createLayer({ id, source, alpha, show, split, order })` +makes one raster layer draped over the terrain and the 3D tiles, under +every ground fill, mover, line and model. Its colours are the image's own, +blended by the texel's alpha times the layer's `alpha`. + +There are two kinds of source: + +| kind | source | for | +| ------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `tiles` | `url` with `{z}`, `{x}`, `{y}` (and `{s}` of `subdomains`), `minLevel`, `maxLevel`, `tileSize` | Recent Imagery's GIBS days, weather tiles, open overlays | +| `image` | `image`: a canvas, an ImageBitmap or image element, or `{ width, height, data }` RGBA bytes; or `url` | the FIRMS heat raster, a field image, event imagery | + +Both take: + +- `rectangle` (`{ west, south, east, north }` in degrees; west > east + crosses the antimeridian): where the layer draws, the globe when omitted; +- `credit`: HTML shown while the layer is. + +Tiles are Web Mercator XYZ tiles and stop at ±85.05°. `opaque` tiles have +no alpha channel, and `maxRequests` caps the layer's tile requests in +flight. An image is stretched over its rectangle in longitude and latitude, +row 0 on the north edge; one over the whole globe wraps across 180°. + +Work with a layer like this: + +- `setSource(source)` draws another source in its place, at its place in + the order; the same canvas or bytes again draw their new pixels; +- `setAlpha(alpha)` fades it: at 0 it draws nothing but stays on its + surface, so it comes back without a gap (the FIRMS hand-off); +- `show(visible)` shows or hides it (Cesium unmaps a hidden layer's tiles); +- `setSplit('left' | 'right' | 'none')` puts it on one side of the split; +- `ready()` says the source is built (an image URL loaded); tiles arrive + after it; +- `destroy()`. + +On the engine: + +- `list()` names the layers in draw order, bottom first: a higher `order` + draws on top, then the later layer; +- `setSplitPosition(fraction)` sets where the split runs, a fraction of the + canvas width from the left (one per engine, Cesium's + `scene.splitPosition`); `splitPosition()` reads it; +- `surface()` says where imagery drapes now: `'globe'`, `'tiles'` (3D tiles) + or `'none'`. + +### How each engine draws imagery + +**Cesium** (`src/globe/cesium/imagery.js`) gives each layer one +`ImageryLayer`: + +- on the globe's imagery layers while the globe shows, else on the shown 3D + tileset's own imagery layers (Cesium drapes imagery on tiles there), as + `src/maps/imageryHost.js` resolves it for the shell; with neither, the + layers wait; +- the engine checks the surface on every frame while it holds a layer, and + builds each layer again on the new stack after a switch; +- above the base imagery, in order among the engine's layers; imagery other + code adds stays where it was put; +- tiles: a `UrlTemplateImageryProvider` in Web Mercator, bounded to the + rectangle; `maxRequests` gates `requestImage`, and a request the gate or + the provider defers asks for one delayed frame, never one per frame; +- an image: one geographic tile over the rectangle (as + `SingleTileImageryProvider`), handed to Cesium as the canvas or image + itself, with no PNG round trip; +- `split` is the layer's `splitDirection`; Cesium splits on the globe only. + +**Navara** (`src/globe/navara/imagery/`) draws every shown layer in one +screen pass right after tone mapping, under the other drapes and +`engine.ground`: + +- each pixel's ground position comes from the scene depth (terrain and 3D + tiles), as `engine.ground`'s pass finds it; +- up to 8 layers are sampled in draw order at its longitude and latitude, + each in its rectangle and on its side of the split (the top 8 when more + are shown); +- an image is one mipmapped texture, sampled in longitude and latitude; +- tiles are an atlas of at most 8×8 Web Mercator tiles over the part of the + rectangle in view, at the level the camera's height asks for; it is + rebuilt when the camera moves to another plan and filled as tiles arrive, + 6 loads at a time (or `maxRequests`); +- texels are blended in display space, as Cesium's globe blends imagery; + mip levels come from the derivatives of the ground position, taken + before any branch, so rectangle edges and the antimeridian show no seams. + +`engine.imagery` is the successor of `engine.maps.addOverlay`, Navara's +undocumented extension for the open map overlays (Sea Marks, Ski Pistes). +That one adds a native Navara raster layer: Navara's own per-tile levels on +the terrain, lit like the base map, but no rectangle, split, alpha or order, +and nothing on 3D tiles. It stays until those overlays move, which needs a +finer Navara tiles path first: a global overlay seen obliquely gets coarse +near the camera from one 8×8 atlas over the whole view. + +### What is still to move + +- **Weather radar, satellite clouds and lightning** (`src/layers/weather/`): + tile templates in geographic and Web Mercator tilings, decoded canvases + tiled into a provider, staged frames (a new frame loads under the shown + one at alpha 0 and replaces it once its tiles are in), a per-tile image + transform (the infrared mask), a detail window over the global image, + and the raised shells Cesium draws over 3D tiles at a height. They need a + geographic `tiles` scheme, a staged `setSource` (or `ready()` per tile + set) and a tile transform; the raised shell is a separate primitive. +- **The wind field** (`src/layers/wind/`): one image over the globe at a + fixed alpha, faded by camera height — an `image` layer — and the same + raised shell on 3D tiles. +- **The Bhote Koshi event imagery** (`src/layers/bhoteKoshi/`, Cesium in + `src/data/bhoteKoshiEvent.js`): two image URLs over one rectangle, split + left and right, alpha 0.96 — two `image` layers with `url` and `split`. + Navara still draws them with `src/layers/recentImagery/render/navaraGround.js`. + ### Choosing a primitive - **A position and an on-screen size:** a mover. @@ -224,3 +337,5 @@ moves to `engine.ground`. - Group items that change together into one set, and replace them in bulk. A ground set is rebuilt on every commit, so an animated fill (a pulse, a flowing route) is not a ground set yet. +- **Pixels on the ground** (tiles, a raster, a photo): an imagery layer. + Fade it with `setAlpha`, and give a new raster to `setSource`. diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index cf9dc25b7..ad3e76a11 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -1,16 +1,16 @@ { "metrics": { - "cesiumLayerRendererLines": 351, - "navaraLayerRendererLines": 2767, - "cesiumImportFiles": 32, - "viewerLines": 1373, - "navaraKindChecks": 7, + "cesiumLayerRendererLines": 330, + "navaraLayerRendererLines": 2304, + "cesiumImportFiles": 31, + "viewerLines": 1367, + "navaraKindChecks": 6, "engineSniffCalls": 14, - "engineExtensionReads": 15, - "groundEffectPasses": 4, + "engineExtensionReads": 12, + "groundEffectPasses": 3, "cesiumSdkLoads": 4, "screenSpaceHandlerFiles": 4, - "engineNameLines": 81 + "engineNameLines": 78 }, - "measuredAt": "after the Street Level, traffic and directions displays, 2026-11-07" + "measuredAt": "after engine.imagery with FIRMS heat and Recent Imagery on it, 2026-11-07" } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1c5814adc..7702ac078 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -937,7 +937,6 @@ "src/layers/recentImagery/model.test.mjs", "src/layers/recentImagery/catalog.test.mjs", "src/maps/imageryHost.test.mjs", - "src/layers/recentImagery/rendering.test.mjs", "src/layers/recentImagery/thumbnails.test.mjs", "src/layers/recentImagery/index.test.mjs", "src/ui/imageryBoxTool.test.mjs", @@ -1402,8 +1401,6 @@ "scripts/fixtures/w2/radio.mjs", "scripts/qa-map-policy.mjs", "scripts/qa-map-policy.test.mjs", - "src/layers/recentImagery/render/tiles.test.mjs", - "src/layers/recentImagery/render/navara.test.mjs", "src/ui/imageryBoxNavara.test.mjs", "scripts/qa-w2-imagery.mjs", "src/globe/navara/postSamplingPass.test.mjs", @@ -1609,10 +1606,8 @@ "src/layers/droneAirspace/render/display.test.mjs", "src/layers/earthquakes/render/display.js", "src/layers/earthquakes/render/display.test.mjs", - "src/layers/firms/render/cesiumHeat.js", "src/layers/firms/render/display.js", "src/layers/firms/render/display.test.mjs", - "src/layers/firms/render/heatField.js", "src/layers/firms/render/sprites.js", "src/layers/ghostMaps/render/display.js", "src/layers/ghostMaps/render/display.test.mjs", @@ -1649,5 +1644,15 @@ "src/layers/traffic/render/display.js", "src/layers/traffic/render/display.test.mjs", "src/layers/directions/edgeCases.test.mjs", - "src/layers/streetLevel/edgeCases.test.mjs" + "src/layers/streetLevel/edgeCases.test.mjs", + "scripts/qa-imagery-compare.mjs", + "src/globe/cesium/imagery.js", + "src/globe/cesium/imagery.test.mjs", + "src/globe/imagery.js", + "src/globe/imagery.test.mjs", + "src/globe/navara/imagery/index.js", + "src/globe/navara/imagery/index.test.mjs", + "src/globe/navara/imagery/pass.js", + "src/layers/recentImagery/render/display.js", + "src/layers/recentImagery/render/display.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fe70fad56..2871a86db 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1601,7 +1601,6 @@ "src/layers/recentImagery/source.js", "src/layers/recentImagery/model.js", "src/layers/recentImagery/catalog.js", - "src/layers/recentImagery/rendering.js", "src/layers/recentImagery/thumbnails.js", "src/maps/imageryHost.js", "src/i18n/index.js", @@ -1638,7 +1637,6 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", - "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", @@ -2680,7 +2678,6 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", - "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", @@ -3194,7 +3191,6 @@ "src/layers/recentImagery/source.js", "src/layers/recentImagery/model.js", "src/layers/recentImagery/catalog.js", - "src/layers/recentImagery/rendering.js", "src/layers/recentImagery/thumbnails.js", "src/maps/imageryHost.js", "src/ui/recentImagery.js", @@ -3482,15 +3478,12 @@ "src/layers/firms/rectangle.js", "src/layers/shared/render/navaraMoverBank.js", "src/layers/shared/render/moverBank.js", - "src/layers/recentImagery/render/navara.js", "src/layers/recentImagery/render/navaraGround.js", - "src/layers/recentImagery/render/tiles.js", "src/layers/cctv/render/navaraMeshes.js", "src/layers/alpr/overlay.js", "src/layers/alpr/presentation.js", "src/layers/radio/screenClusters.js", "src/layers/shared/render/imageAtlas.js", - "src/layers/firms/render/navaraHeat.js", "src/layers/shared/render/navaraGroundSurface.js", "src/layers/shared/render/navaraGroundEffect.js", "src/annotations/outlineRungs.js", @@ -3570,8 +3563,6 @@ "src/layers/ghostMaps/render/display.js", "src/layers/perimeters/render/display.js", "src/layers/gnss/render/display.js", - "src/layers/firms/render/cesiumHeat.js", - "src/layers/firms/render/heatField.js", "src/layers/firms/render/display.js", "src/layers/installations/render/style.js", "src/layers/awareness/render/cesiumPulses.js", @@ -3583,7 +3574,12 @@ "src/layers/directions/render/style.js", "src/layers/directions/render/display.js", "src/layers/streetLevel/render/display.js", - "src/layers/traffic/render/display.js" + "src/layers/traffic/render/display.js", + "src/layers/recentImagery/render/display.js", + "src/globe/navara/imagery/pass.js", + "src/globe/navara/imagery/index.js", + "src/globe/imagery.js", + "src/globe/cesium/imagery.js" ], "external": [ "hls.js", @@ -3816,7 +3812,6 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", - "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", @@ -4102,7 +4097,6 @@ "src/layers/recentImagery/source.js", "src/layers/recentImagery/model.js", "src/layers/recentImagery/catalog.js", - "src/layers/recentImagery/rendering.js", "src/layers/recentImagery/thumbnails.js", "src/maps/imageryHost.js", "src/layers/streetLevel/index.js", @@ -4311,8 +4305,6 @@ "src/layers/droneAirspace/render/display.js", "src/globe/ground.js", "src/layers/ghostMaps/render/display.js", - "src/layers/firms/render/cesiumHeat.js", - "src/layers/firms/render/heatField.js", "src/layers/firms/render/display.js", "src/layers/infrastructure/render/display.js", "src/layers/installations/render/style.js", @@ -4324,7 +4316,8 @@ "src/layers/directions/render/style.js", "src/layers/directions/render/display.js", "src/layers/streetLevel/render/display.js", - "src/layers/traffic/render/display.js" + "src/layers/traffic/render/display.js", + "src/layers/recentImagery/render/display.js" ], "external": [ "hls.js", diff --git a/scripts/qa-imagery-compare.mjs b/scripts/qa-imagery-compare.mjs new file mode 100644 index 000000000..19614c844 --- /dev/null +++ b/scripts/qa-imagery-compare.mjs @@ -0,0 +1,438 @@ +#!/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/src/app/layers/recentImagery.js b/src/app/layers/recentImagery.js index 27001ddb7..ca0872066 100644 --- a/src/app/layers/recentImagery.js +++ b/src/app/layers/recentImagery.js @@ -1,22 +1,21 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js'; -import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js'; import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js'; -import { governorRequestRender } from '../../renderGovernor.js'; import { rendererFor } from './renderers.js'; -/** Construct imagery presentation over one explicitly supplied acquisition source. */ +/** + * Construct imagery presentation over one explicitly supplied acquisition + * source; it draws with the engine's renderer from the registry + * (render/display.js on every engine). + */ export function createApplicationRecentImagery({ source }) { const options = { - requestRender: governorRequestRender, tileTemplate: (...args) => source.getTileTemplate(...args), credit: source.credit || source.label || 'Imagery', }; return createRecentImageryLayer({ catalog: { searchHls: (...args) => source.searchHls(...args) }, rendererFactory: ({ engine }) => - engine?.kind === 'navara' - ? rendererFor('recent-imagery', engine, options) - : createRecentImageryRenderer(options), + rendererFor('recent-imagery', engine, options), thumbnails: createThumbnailLoader({ requestThumbnail: (...args) => source.getThumbnail(...args), }), diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index d6d9ca86a..8a536b7f7 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -52,6 +52,7 @@ import { createAwarenessDisplay } from '../../layers/awareness/render/display.js import { createStreetLevelDisplay } from '../../layers/streetLevel/render/display.js'; import { createTrafficDisplay } from '../../layers/traffic/render/display.js'; import { createDirectionsDisplay } from '../../layers/directions/render/display.js'; +import { createRecentImageryDisplay } from '../../layers/recentImagery/render/display.js'; const ENGINE_KINDS = new Set(['cesium', 'navara']); @@ -139,6 +140,7 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({ 'street-level': createStreetLevelDisplay, traffic: createTrafficDisplay, directions: createDirectionsDisplay, + 'recent-imagery': createRecentImageryDisplay, }); // The other application layers still draw themselves on Cesium. diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index 6be4379b1..5bb1d84e7 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -88,6 +88,7 @@ test('the layers with extracted renderers', () => { 'street-level', 'traffic', 'directions', + 'recent-imagery', ]); }); @@ -117,6 +118,8 @@ test('the layers drawn through one display get the same factory on every engine' 'street-level': ['streetLevel', 'createStreetLevelDisplay'], traffic: ['traffic', 'createTrafficDisplay'], directions: ['directions', 'createDirectionsDisplay'], + 'local-firms': ['firms', 'createFirmsDisplay'], + 'recent-imagery': ['recentImagery', 'createRecentImageryDisplay'], 'telegeography-submarine-cables': [ 'submarineCables', 'createCablesDisplay', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 1c7379b9a..c94094ab6 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -94,15 +94,9 @@ const NAVARA_CREDIT_KEYS = new Set([ */ export async function registerNavaraLayerRenderers() { const loaders = { - 'recent-imagery': async () => { - const { createRecentImageryNavaraRenderer } = - await import('../layers/recentImagery/render/navara.js'); - return (engine, options) => - createRecentImageryNavaraRenderer(engine, { - ...options, - ...engine.extensions.navara, - }); - }, + 'recent-imagery': async () => + (await import('../layers/recentImagery/render/display.js')) + .createRecentImageryDisplay, transit: async () => (await import('../layers/transit/render/display.js')) .createTransitDisplay, @@ -198,19 +192,8 @@ export async function registerNavaraLayerRenderers() { 'military-installations': async () => (await import('../layers/installations/render/display.js')) .createInstallationsDisplay, - // The FIRMS display, with the Navara backend of its heatmap. - 'local-firms': async () => { - const [{ createFirmsDisplay }, { createFirmsHeatDrape }] = - await Promise.all([ - import('../layers/firms/render/display.js'), - import('../layers/firms/render/navaraHeat.js'), - ]); - return (engine, options) => - createFirmsDisplay(engine, { - ...options, - navaraHeat: createFirmsHeatDrape, - }); - }, + 'local-firms': async () => + (await import('../layers/firms/render/display.js')).createFirmsDisplay, 'osm-places': async () => (await import('../layers/osmPlaces/render/display.js')) .createOsmPlacesDisplay, diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 754493754..a48c35a89 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -8,6 +8,7 @@ import { blurGaussian, buildHeatRaster, } from './firmsHeatRaster.js'; +import { HEAT_RASTER } from '../layers/firms/policy.js'; const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); @@ -116,3 +117,26 @@ test('buildHeatRaster with no fires is fully transparent', () => { const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 }); assert.ok(r.rgba.every((v) => v === 0)); }); + +test('the shipped raster paints a lone weak fire as a distinct spot', () => { + // A hot cluster elsewhere sets the scale, as Africa does on real data. + const fires = [{ lat: 0, lon: 0, frp: 5 }]; + for (let i = 0; i < 40; i++) { + fires.push({ lat: -10 + (i % 5) * 0.1, lon: 25 + i * 0.05, frp: 300 }); + } + const r = buildHeatRaster(fires, HEAT_RASTER); + const y = Math.floor((90 / 180) * r.height); + const painted = []; + for (let x = 0; x < r.width; x++) { + const lon = ((x + 0.5) / r.width) * 360 - 180; + if (Math.abs(lon) < 5 && r.rgba[(y * r.width + x) * 4 + 3] > 0) { + painted.push(x); + } + } + const kmPerPx = 40075 / r.width; + assert.ok(painted.length > 0, 'the weak fire still shows'); + assert.ok( + painted.length * kmPerPx <= 100, + `spot ${Math.round(painted.length * kmPerPx)} km wide`, + ); +}); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index 611947fe3..45ba8e80d 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -44,6 +44,13 @@ function makeFire(overrides = {}) { }; } +/** The globe's imagery stack, shown: where the engine drapes the heatmap. */ +function globeImagery(viewer) { + viewer.imageryLayers = new Cesium.ImageryLayerCollection(); + viewer.scene.globe.show = true; + return viewer.imageryLayers; +} + /** * Stand the layer up far enough to drive one click. `cardHit` is what the * overlay host reports under the click position — the real host only publishes @@ -543,28 +550,15 @@ test('the heatmap is preloaded under the markers before the first zoom-out', asy const h = harness({ cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 1_000_000), }); - const added = []; + // The engine drapes the heatmap on the globe's own imagery. + const added = globeImagery(h.viewer); try { - h.layer.attachShellServices({ - imageryHost: () => ({ - kind: 'globe', - collection: { - addImageryProvider: (provider) => { - const layer = { provider, show: true, alpha: 1 }; - added.push(layer); - return layer; - }, - remove() {}, - raiseToTop() {}, - }, - }), - }); h.layer._renderLodForTest('close'); assert.equal(added.length, 0, 'the markers paint first; the build trails'); t.mock.timers.tick(HEAT_REBUILD_MS); assert.equal(added.length, 1, 'then the heatmap is draped'); - assert.equal(added[0].alpha, 0, 'invisible until the hand-off'); - assert.equal(added[0].show, true, 'but mapped onto the globe tiles'); + assert.equal(added.get(0).alpha, 0, 'invisible until the hand-off'); + assert.equal(added.get(0).show, true, 'but mapped onto the globe tiles'); } finally { h.cleanup(); if (priorDocument === undefined) delete globalThis.document; @@ -596,8 +590,9 @@ test('far zoom drapes one heatmap and the hand-off cross-fades it with the marke fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })], cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000), }); - const added = []; + const added = globeImagery(h.viewer); const removed = []; + added.layerRemoved.addEventListener((layer) => removed.push(layer)); // The markers as drawn: how many the sprite set draws, and their alphas. const drawn = () => { const count = @@ -607,23 +602,12 @@ test('far zoom drapes one heatmap and the hand-off cross-fades it with the marke return Array.from({ length: count }, (_, i) => markers.get(i).color.alpha); }; try { - const collection = { - addImageryProvider: (provider) => { - const layer = { provider, show: true, alpha: 1 }; - added.push(layer); - return layer; - }, - remove: (layer, destroy) => removed.push({ layer, destroy }), - raiseToTop() {}, - }; - h.layer.attachShellServices({ - imageryHost: () => ({ kind: 'globe', collection }), - }); h.layer._renderLodForTest('global'); await settle(); assert.equal(added.length, 1, 'one heat imagery layer'); - assert.equal(added[0].show, true); - assert.equal(added[0].alpha, 1); + const heat = added.get(0); + assert.equal(heat.show, true); + assert.equal(heat.alpha, 1); assert.equal(h.layer._cellCountForTest(), 0, 'no ground cells'); assert.ok(h.published.length > 0, 'the far band still paints cards'); assert.deepEqual(drawn(), [], 'no marker drawn while the heatmap is whole'); @@ -643,34 +627,33 @@ test('far zoom drapes one heatmap and the hand-off cross-fades it with the marke h.layer._renderLodForTest('regional'); assert.equal(added.length, 1, 'switching far bands reuses the raster'); + assert.equal(added.get(0), heat); h.setHeight(1_000_000); h.layer._renderLodForTest('close'); await settle(); - assert.equal(added.length, 1, 'the markers band keeps the same heat layer'); - assert.equal(added[0].show, true, 'still mapped onto the globe tiles'); - assert.equal(added[0].alpha, 0, 'faded out under the markers'); + assert.equal(added.get(0), heat, 'the markers band keeps the same layer'); + assert.equal(heat.show, true, 'still mapped onto the globe tiles'); + assert.equal(heat.alpha, 0, 'faded out under the markers'); assert.deepEqual(drawn(), [1, 1]); // Mid hand-off both are drawn, half each, in either band. h.setHeight(4_500_000); h.layer._applyHandoffFadeForTest(); await settle(); - assert.equal(added[0].alpha, 0.5); + assert.equal(heat.alpha, 0.5); assert.deepEqual(drawn(), [0.5, 0.5]); h.layer._renderLodForTest('regional'); await settle(); - assert.equal(added[0].alpha, 0.5); + assert.equal(heat.alpha, 0.5); assert.deepEqual(drawn(), [0.5, 0.5], 'rebuilt markers get the fade'); h.layer.disable(); - assert.equal(added[0].show, false, 'disable hides the heatmap'); + assert.equal(heat.show, false, 'disable hides the heatmap'); h.layer.destroy(); - assert.deepEqual( - removed.map((entry) => [entry.layer, entry.destroy]), - [[added[0], true]], - ); + assert.deepEqual(removed, [heat]); + assert.equal(heat.isDestroyed(), true); } finally { h.cleanup(); restoreDocument(); diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index ca9f07c40..40abc752f 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -5,13 +5,15 @@ // engine clock's frame latch on viewer.clock.onTick (clock.js), attached at // creation so it runs before onTick listeners added later. Mover sets and // lines join the scene in one engine-owned collection each, with the first -// set or line; ground sets join scene.groundPrimitives the same way. +// set or line; ground sets join scene.groundPrimitives the same way, and +// imagery layers the imagery stack of the shown surface. import { createCesiumCamera } from './camera.js'; import { createCesiumClock } from './clock.js'; import { createCesiumCredits } from './credits.js'; import { createCesiumFrames } from './frames.js'; import { createCesiumGround } from './ground.js'; +import { createCesiumImagery } from './imagery.js'; import { createCesiumInput } from './input.js'; import { createCesiumLines } from './lines.js'; import { createCesiumMaps } from './maps.js'; @@ -33,9 +35,11 @@ import { createCesiumSurface } from './surface.js'; * moverFactories?: object, * lineFactories?: object, * groundFactories?: object, + * imageryFactories?: object, * }} [options] `requestRender` defaults to the render governor; `appClock` * to the page's app clock; the `create*`/`load*` options and the mover and - * line and ground factories (movers.js, lines.js, ground.js) are test seams. + * line, ground and imagery factories (movers.js, lines.js, ground.js, + * imagery.js) are test seams. * * `render.setContinuous` takes a hold on the app's singleton renderGovernor * (src/renderGovernor.js), which governs the one viewer it was installed on; @@ -60,6 +64,11 @@ export function createCesiumEngine(viewer, options = {}) { requestRender, ...(options.groundFactories ? { factories: options.groundFactories } : {}), }); + const maps = createCesiumMaps(viewer, { + ...options, + credits: credits.credits, + requestRender, + }); // Picks name the mover set, line or ground set they hit, with its pickId. const picker = createCesiumPicker(viewer, { identify: (picked) => @@ -83,11 +92,15 @@ export function createCesiumEngine(viewer, options = {}) { }), input: createCesiumInput(viewer, { ...options, picker: picker.picker }), credits, - maps: createCesiumMaps(viewer, { - ...options, - credits: credits.credits, + // Before the maps: its layers leave the tilesets before they go. + imagery: createCesiumImagery(viewer, { requestRender, + tilesets: maps.tilesets, + ...(options.imageryFactories + ? { factories: options.imageryFactories } + : {}), }), + maps, }; let destroyed = false; return { diff --git a/src/globe/cesium/imagery.js b/src/globe/cesium/imagery.js new file mode 100644 index 000000000..2e03c677e --- /dev/null +++ b/src/globe/cesium/imagery.js @@ -0,0 +1,349 @@ +// engine.imagery on Cesium: one ImageryLayer per layer, on the imagery +// stack of the surface the map shows — the globe's imagery layers, or the +// shown 3D tileset's own (Cesium drapes imagery on tiles there), as +// src/maps/imageryHost.js resolves it for the shell. Layers sit above the +// base imagery, in their order among the engine's layers (higher on top, +// then creation); imagery added by others stays where it was put. A tiles +// source is a UrlTemplateImageryProvider in Web Mercator, an image source a +// one-tile provider over its rectangle (as SingleTileImageryProvider, but +// from the canvas or image itself, without a PNG round trip). The engine +// follows the surface on every frame while it holds a layer: on a switch +// each layer is built again on the new stack, never re-parented. + +import * as Cesium from 'cesium'; +import { createImageryRegistry, isPixelImage } from '../imagery.js'; + +const SPLIT = { none: 'NONE', left: 'LEFT', right: 'RIGHT' }; +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; + +const rectangleOf = ({ west, south, east, north }) => + Cesium.Rectangle.fromDegrees(west, south, east, north); + +const defaultCanvas = (width, height) => + Object.assign(document.createElement('canvas'), { width, height }); + +// RGBA bytes on a canvas Cesium can upload. +function drawable(image, createCanvas) { + if (!isPixelImage(image)) return image; + const canvas = createCanvas(image.width, image.height); + const context = canvas.getContext('2d'); + const pixels = context.createImageData(image.width, image.height); + pixels.data.set(image.data); + context.putImageData(pixels, 0, 0); + return canvas; +} + +// One image over `rectangle`: SingleTileImageryProvider's tiling (one +// geographic tile at level 0), handing Cesium the image itself. +function singleImageProvider(image, rectangle, credit) { + const tilingScheme = new Cesium.GeographicTilingScheme({ + rectangle, + numberOfLevelZeroTilesX: 1, + numberOfLevelZeroTilesY: 1, + }); + return { + tilingScheme, + rectangle, + tileWidth: image.width, + tileHeight: image.height, + minimumLevel: 0, + maximumLevel: 0, + tileDiscardPolicy: undefined, + credit: credit ? new Cesium.Credit(credit) : undefined, + errorEvent: new Cesium.Event(), + hasAlphaChannel: true, + proxy: undefined, + getTileCredits: () => undefined, + pickFeatures: () => undefined, + // ImageryLayer consumes requestImage results as promises. + requestImage: () => Promise.resolve(image), + }; +} + +/** + * Gate a provider's `requestImage` to `limit` requests in flight. Returning + * `undefined` tells Cesium to retry on a later frame, but under + * `requestRenderMode` there is no later frame unless something asks — and a + * deferral must never ask for one itself: the retried tile would defer again + * and ask again, keeping the scene rendering every frame. So a request that + * finds the gate full asks for nothing (the request holding the slot asks + * 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) { + const original = provider.requestImage.bind(provider); + provider.requestImage = (x, y, level, request) => { + if (admission.inFlight >= admission.limit) return undefined; + admission.inFlight += 1; + const release = () => { + admission.inFlight -= 1; + }; + let promise; + try { + promise = original(x, y, level, request); + } catch (error) { + release(); + throw error; + } + if (!promise) { + release(); + retry.schedule(); + return undefined; + } + return Promise.resolve(promise).finally(() => { + release(); + requestRender('imagery-tiles'); + }); + }; + return provider; +} + +const defaultFactories = Object.freeze({ + tiles: (options) => new Cesium.UrlTemplateImageryProvider(options), + layer: (provider, options) => new Cesium.ImageryLayer(provider, options), + loadImage: (url) => Cesium.Resource.fetchImage({ url }), +}); + +/** + * @param {Cesium.Viewer} viewer + * @param {{ requestRender?: (reason: string) => void, + * tilesets?: () => object[], createCanvas?: (width: number, height: number) => object, + * factories?: object, setTimeout?: Function, clearTimeout?: Function }} [options] + * `tilesets` are the 3D tilesets the map may show (maps.js); a shown one + * with imagery layers hosts the imagery while the globe is hidden. + * `createCanvas`, `factories` (the tile provider, the layer, the image + * load) and the timers are test seams. + * @returns {{ imagery: import('../contract.js').GlobeImagery, destroy: () => void }} + */ +export function createCesiumImagery( + viewer, + { + requestRender = () => viewer.scene.requestRender(), + tilesets = () => [], + createCanvas = defaultCanvas, + factories = {}, + setTimeout: after = (run, ms) => globalThis.setTimeout(run, ms), + clearTimeout: cancel = (id) => globalThis.clearTimeout(id), + } = {}, +) { + const make = { ...defaultFactories, ...factories }; + const { scene } = viewer; + const entries = new Set(); + let removeWatch = null; + + function host() { + if (scene.globe?.show === true && viewer.imageryLayers) + return { collection: viewer.imageryLayers, surface: 'globe' }; + const tileset = tilesets().find( + (candidate) => + candidate?.show === true && + candidate.imageryLayers && + !candidate.isDestroyed?.(), + ); + return tileset + ? { collection: tileset.imageryLayers, surface: 'tiles' } + : NO_HOST; + } + + const usable = (collection) => collection && !collection.isDestroyed?.(); + function detach(entry) { + const { layer, collection } = entry; + entry.layer = null; + entry.collection = null; + if (!layer) return; + if (usable(collection) && collection.contains(layer)) + collection.remove(layer, true); + else if (!layer.isDestroyed?.()) layer.destroy?.(); + } + + // Above every engine layer under it in draw order, below every one over + // it; at the top when none is over it. + function place(entry, layer, collection) { + let index = collection.length; + for (const other of entries) { + if (other === entry || other.collection !== collection || !other.layer) + continue; + const over = + other.state.options.order > entry.state.options.order || + (other.state.options.order === entry.state.options.order && + other.state.seq > entry.state.seq); + if (over) index = Math.min(index, collection.indexOf(other.layer)); + } + collection.add(layer, index); + } + + function providerFor(entry, source) { + if (source.kind === 'tiles') { + const provider = make.tiles({ + url: source.url, + subdomains: [...source.subdomains], + minimumLevel: source.minLevel, + maximumLevel: source.maxLevel, + tileWidth: source.tileSize, + tileHeight: source.tileSize, + tilingScheme: new Cesium.WebMercatorTilingScheme(), + rectangle: rectangleOf(source.rectangle), + hasAlphaChannel: !source.opaque, + enablePickFeatures: false, + ...(source.credit ? { credit: source.credit } : {}), + }); + return source.maxRequests + ? admitTileRequests( + provider, + { limit: source.maxRequests, inFlight: 0 }, + requestRender, + entry.retry, + ) + : provider; + } + return singleImageProvider( + drawable(entry.image, createCanvas), + rectangleOf(source.rectangle), + source.credit, + ); + } + + function applyLook(entry) { + const { layer, state } = entry; + if (!layer) return; + layer.alpha = state.alpha; + layer.show = state.shown; + layer.splitDirection = + Cesium.SplitDirection?.[SPLIT[state.split]] ?? layer.splitDirection; + } + + // The layer for the current source on the current host, in place of the + // one drawn (at its index, so a new source keeps its stack position). + function mount(entry) { + const { collection } = host(); + const source = entry.state.source; + if (!usable(collection) || (source.kind === 'image' && !entry.image)) { + detach(entry); + return; + } + const layer = make.layer(providerFor(entry, source), { + alpha: entry.state.alpha, + show: entry.state.shown, + }); + const previous = entry.layer; + if (previous && entry.collection === collection) { + collection.add(layer, collection.indexOf(previous)); + collection.remove(previous, true); + } else { + detach(entry); + place(entry, layer, collection); + } + entry.layer = layer; + entry.collection = collection; + applyLook(entry); + requestRender('imagery'); + } + + // An image source: its image now, or once its URL has loaded. + function load(entry) { + const source = entry.state.source; + entry.loading = null; + if (source.kind !== 'image' || source.image) { + entry.image = source.image; + mount(entry); + return; + } + entry.image = null; + const pending = Promise.resolve(make.loadImage(source.url)); + entry.loading = pending; + pending.then( + (image) => { + if (entry.loading !== pending || entry.state.destroyed) return; + entry.loading = null; + entry.image = image; + mount(entry); + }, + (error) => { + if (entry.loading !== pending) return; + entry.loading = null; + console.warn('[Imagery] image failed to load:', error); + }, + ); + } + + // Follow the surface: a layer whose stack is not the current one is + // built again there (none: it waits without a layer). + const follow = () => { + const { collection } = host(); + for (const entry of entries) + if (entry.collection !== collection && (entry.layer || collection)) + mount(entry); + }; + const watch = () => { + if (!removeWatch && scene.preRender) + removeWatch = scene.preRender.addEventListener(follow); + }; + const unwatch = () => { + if (entries.size || !removeWatch) return; + removeWatch(); + removeWatch = null; + }; + + const registry = createImageryRegistry( + (state) => { + let retryTimer = null; + const entry = { + state, + layer: null, + collection: null, + image: null, + loading: null, + // One timer per layer: any number of provider deferrals while it is + // armed share the same single retry frame. + retry: { + schedule() { + if (retryTimer !== null) return; + retryTimer = after(() => { + retryTimer = null; + if (!state.destroyed) requestRender('imagery-retry'); + }, TILE_RETRY_DELAY_MS); + }, + cancel() { + if (retryTimer !== null) cancel(retryTimer); + retryTimer = null; + }, + }, + }; + entries.add(entry); + watch(); + load(entry); + return { + source: () => load(entry), + look() { + applyLook(entry); + requestRender('imagery-look'); + }, + ready: () => Boolean(entry.layer?.ready), + destroy() { + entries.delete(entry); + entry.loading = null; + entry.retry.cancel(); + detach(entry); + unwatch(); + requestRender('imagery-destroy'); + }, + }; + }, + { + // The scene's own: the shell's swipe controls set it too. + readSplit: () => + Number.isFinite(scene.splitPosition) ? scene.splitPosition : 0.5, + splitPosition(fraction) { + scene.splitPosition = fraction; + requestRender('imagery-split'); + }, + surface: () => host().surface, + }, + ); + return { + imagery: registry.imagery, + destroy: () => registry.destroyAll(), + }; +} diff --git a/src/globe/cesium/imagery.test.mjs b/src/globe/cesium/imagery.test.mjs new file mode 100644 index 000000000..f9a22f951 --- /dev/null +++ b/src/globe/cesium/imagery.test.mjs @@ -0,0 +1,345 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { TILE_RETRY_DELAY_MS, createCesiumImagery } from './imagery.js'; + +const BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 }; +const tiles = (extra = {}) => ({ + kind: 'tiles', + url: 'https://gibs-{s}.example/{z}/{y}/{x}.png', + maxLevel: 12, + rectangle: BOX, + ...extra, +}); +const pixels = (width = 4, height = 2) => ({ + width, + height, + data: new Uint8ClampedArray(width * height * 4), +}); + +/** A tile provider whose requests stay pending until the test settles them. */ +class PendingProvider { + constructor(options) { + this.options = options; + this.calls = []; + this.pending = []; + // What a Cesium ImageryLayer reads of its provider. + this.tilingScheme = + options.tilingScheme ?? new Cesium.WebMercatorTilingScheme(); + this.rectangle = options.rectangle ?? this.tilingScheme.rectangle; + this.tileWidth = this.tileHeight = 256; + this.minimumLevel = 0; + this.maximumLevel = options.maximumLevel ?? 18; + this.errorEvent = new Cesium.Event(); + } + requestImage(x, y, level) { + this.calls.push([x, y, level]); + return new Promise((resolve) => this.pending.push(resolve)); + } +} + +function manualTimers() { + const pending = new Map(); + let next = 1; + return { + pending, + setTimeout(run, ms) { + const id = next++; + pending.set(id, { run, ms }); + return id; + }, + clearTimeout: (id) => pending.delete(id), + flush() { + const entries = [...pending.values()]; + pending.clear(); + for (const entry of entries) entry.run(); + }, + }; +} + +function fixture({ requestImage } = {}) { + const preRender = new Cesium.Event(); + const viewer = { + imageryLayers: new Cesium.ImageryLayerCollection(), + scene: { globe: { show: true }, preRender, splitPosition: 0.5 }, + }; + const renders = []; + const timers = manualTimers(); + const tileset = { + show: false, + imageryLayers: new Cesium.ImageryLayerCollection(), + isDestroyed: () => false, + }; + const providers = []; + const { imagery, destroy } = createCesiumImagery(viewer, { + requestRender: (reason) => renders.push(reason), + tilesets: () => [tileset], + createCanvas: (width, height) => ({ + width, + height, + getContext: () => ({ + createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }), + putImageData() {}, + }), + }), + factories: { + tiles(options) { + const provider = new PendingProvider(options); + if (requestImage) provider.requestImage = requestImage; + providers.push(provider); + return provider; + }, + }, + setTimeout: timers.setTimeout, + clearTimeout: timers.clearTimeout, + }); + const globe = viewer.imageryLayers; + const frame = () => preRender.raiseEvent(); + return { + viewer, + imagery, + destroy, + globe, + tileset, + providers, + renders, + timers, + frame, + tileFrames: () => renders.filter((r) => r === 'imagery-tiles').length, + }; +} + +const layersOf = (collection) => + Array.from({ length: collection.length }, (_, i) => collection.get(i)); + +test('a tiles layer is one Web Mercator template provider bounded to its rectangle', () => { + const f = fixture(); + const layer = f.imagery.createLayer({ + id: 'a', + source: tiles({ credit: 'NASA GIBS', opaque: true, maxLevel: 9 }), + alpha: 0.8, + split: 'left', + }); + const [drawn] = layersOf(f.globe); + const { options } = drawn.imageryProvider; + assert.equal(options.url, 'https://gibs-{s}.example/{z}/{y}/{x}.png'); + assert.deepEqual(options.subdomains, ['a', 'b', 'c']); + assert.equal(options.maximumLevel, 9); + assert.equal(options.hasAlphaChannel, false, 'opaque tiles have no alpha'); + assert.equal(options.credit, 'NASA GIBS'); + assert.ok(options.tilingScheme instanceof Cesium.WebMercatorTilingScheme); + assert.ok( + Cesium.Rectangle.equalsEpsilon( + options.rectangle, + Cesium.Rectangle.fromDegrees(-97.8, 30.2, -97.7, 30.3), + 1e-12, + ), + ); + assert.equal(drawn.alpha, 0.8); + assert.equal(drawn.splitDirection, Cesium.SplitDirection.LEFT); + assert.equal(layer.ready(), true); + layer.setAlpha(0.25); + layer.show(false); + layer.setSplit('right'); + assert.deepEqual(layersOf(f.globe), [drawn], 'a restyle keeps the layer'); + assert.deepEqual( + [drawn.alpha, drawn.show, drawn.splitDirection], + [0.25, false, Cesium.SplitDirection.RIGHT], + ); + // A new source replaces the layer at its place, keeping the look. + f.imagery.createLayer({ id: 'over', source: tiles(), order: 1 }); + layer.setSource(tiles({ url: 'https://other.example/{z}/{x}/{y}.png' })); + const [replaced, over] = layersOf(f.globe); + assert.equal(drawn.isDestroyed(), true); + assert.equal( + replaced.imageryProvider.options.url, + 'https://other.example/{z}/{x}/{y}.png', + ); + assert.equal(over.imageryProvider.options.url, tiles().url); + assert.deepEqual([replaced.alpha, replaced.show], [0.25, false]); + f.destroy(); + assert.equal(f.globe.length, 0); +}); + +test('an image layer is one geographic tile of the image itself over its rectangle', async () => { + const f = fixture(); + const image = pixels(8, 4); + f.imagery.createLayer({ id: 'heat', source: { kind: 'image', image } }); + const provider = f.globe.get(0).imageryProvider; + assert.ok(provider.tilingScheme instanceof Cesium.GeographicTilingScheme); + assert.deepEqual( + [ + provider.tileWidth, + provider.tileHeight, + provider.maximumLevel, + provider.tilingScheme.getNumberOfXTilesAtLevel(0), + provider.tilingScheme.getNumberOfYTilesAtLevel(0), + ], + [8, 4, 0, 1, 1], + ); + assert.ok( + Cesium.Rectangle.equalsEpsilon( + provider.rectangle, + Cesium.Rectangle.fromDegrees(-180, -90, 180, 90), + 1e-12, + ), + ); + const drawn = await provider.requestImage(0, 0, 0); + assert.equal(drawn.width, 8, 'RGBA bytes go on a canvas'); + assert.equal(typeof drawn.getContext, 'function'); +}); + +test('layers sit over the base imagery in their order, then creation; others stay put', () => { + const f = fixture(); + const base = new Cesium.ImageryLayer(new PendingProvider({})); + f.globe.add(base); + f.imagery.createLayer({ id: 'high', source: tiles(), order: 5 }); + f.imagery.createLayer({ id: 'low', source: tiles(), order: -1 }); + f.imagery.createLayer({ id: 'mid', source: tiles() }); + f.imagery.createLayer({ id: 'mid-later', source: tiles() }); + assert.deepEqual( + f.imagery.list().map(({ id }) => id), + ['low', 'mid', 'mid-later', 'high'], + ); + const layers = layersOf(f.globe); + assert.equal(layers[0], base); + assert.equal(layers.length, 5); + // Each engine layer's provider was made in creation order. + const created = ['high', 'low', 'mid', 'mid-later']; + const drawnOrder = layers + .slice(1) + .map((layer) => created[f.providers.indexOf(layer.imageryProvider)]); + assert.deepEqual(drawnOrder, ['low', 'mid', 'mid-later', 'high']); +}); + +test('the layers follow the surface: the globe, a shown tileset, none, and back', () => { + const f = fixture(); + const layer = f.imagery.createLayer({ + id: 'a', + source: tiles(), + alpha: 0.6, + split: 'left', + }); + assert.equal(f.imagery.surface(), 'globe'); + f.viewer.scene.globe.show = false; + f.tileset.show = true; + assert.equal(f.imagery.surface(), 'tiles'); + f.frame(); + assert.equal(f.globe.length, 0, 'the globe stack is left empty'); + const [onTiles] = layersOf(f.tileset.imageryLayers); + assert.deepEqual( + [onTiles.alpha, onTiles.splitDirection], + [0.6, Cesium.SplitDirection.LEFT], + ); + f.tileset.show = false; + assert.equal(f.imagery.surface(), 'none'); + f.frame(); + assert.equal(f.tileset.imageryLayers.length, 0); + assert.equal(layer.ready(), false, 'no surface: nothing drawn'); + f.viewer.scene.globe.show = true; + f.frame(); + assert.equal(f.globe.length, 1); + assert.equal(layer.ready(), true); + layer.destroy(); + assert.equal(f.globe.length, 0); + assert.equal(f.viewer.scene.preRender.numberOfListeners, 0, 'unwatched'); +}); + +test('the split position is the scene’s, set by the engine or the shell', () => { + const f = fixture(); + assert.equal(f.imagery.splitPosition(), 0.5); + f.imagery.setSplitPosition(0.3); + assert.equal(f.viewer.scene.splitPosition, 0.3); + // The shell's swipe writes the scene too: the engine reads it. + f.viewer.scene.splitPosition = 0.7; + assert.equal(f.imagery.splitPosition(), 0.7); + f.imagery.setSplitPosition(0.3); + assert.equal(f.viewer.scene.splitPosition, 0.3); +}); + +test('above maxRequests a tile request defers silently; its settlement asks for one frame', async () => { + const f = fixture(); + f.imagery.createLayer({ id: 'a', source: tiles({ maxRequests: 1 }) }); + const provider = f.globe.get(0).imageryProvider; + const first = provider.requestImage(0, 0, 1); + assert.ok(first instanceof Promise); + for (let i = 1; i <= 120; i += 1) + assert.equal(provider.requestImage(i, 0, 1), undefined); + assert.equal( + f.providers[0].calls.length, + 1, + 'deferred requests never go out', + ); + assert.equal(f.tileFrames(), 0, 'a deferral asks for no frame'); + assert.equal(f.timers.pending.size, 0); + f.providers[0].pending[0]('tile'); + await first; + assert.equal(f.tileFrames(), 1, 'the settlement asks for the retry frame'); + assert.ok(provider.requestImage(200, 0, 1) instanceof Promise); +}); + +test('provider deferrals share one delayed retry frame per layer; destroy disarms it', () => { + let throwNext = false; + const f = fixture({ + requestImage() { + if (throwNext) throw new Error('boom'); + return undefined; + }, + }); + const layer = f.imagery.createLayer({ + id: 'a', + source: tiles({ maxRequests: 1 }), + }); + const provider = f.globe.get(0).imageryProvider; + for (let i = 0; i < 40; i += 1) + assert.equal(provider.requestImage(i, 0, 0), undefined); + assert.equal(f.timers.pending.size, 1); + assert.equal([...f.timers.pending.values()][0].ms, TILE_RETRY_DELAY_MS); + f.timers.flush(); + assert.equal( + f.renders.filter((reason) => reason === 'imagery-retry').length, + 1, + ); + // A throwing provider releases its slot too. + throwNext = true; + assert.throws(() => provider.requestImage(0, 0, 0), /boom/); + throwNext = false; + provider.requestImage(0, 0, 0); + assert.equal(f.timers.pending.size, 1, 'the next deferral arms a timer'); + layer.destroy(); + assert.equal(f.timers.pending.size, 0, 'destroy disarms the retry'); +}); + +test('an image URL draws once it has loaded; a newer source wins', async () => { + const loads = []; + const preRender = new Cesium.Event(); + const viewer = { + imageryLayers: new Cesium.ImageryLayerCollection(), + scene: { globe: { show: true }, preRender }, + }; + const { imagery } = createCesiumImagery(viewer, { + requestRender() {}, + factories: { + loadImage: (url) => + new Promise((resolve) => loads.push({ url, resolve })), + }, + }); + const layer = imagery.createLayer({ + id: 'event', + source: { kind: 'image', url: 'before.webp', rectangle: BOX }, + }); + assert.equal(viewer.imageryLayers.length, 0); + assert.equal(layer.ready(), false); + layer.setSource({ kind: 'image', url: 'after.webp', rectangle: BOX }); + loads[0].resolve({ width: 2, height: 2, name: 'before' }); + await Promise.resolve(); + assert.equal(viewer.imageryLayers.length, 0, 'a superseded load is dropped'); + loads[1].resolve({ width: 2, height: 2, name: 'after' }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(viewer.imageryLayers.length, 1); + const image = await viewer.imageryLayers + .get(0) + .imageryProvider.requestImage(0, 0, 0); + assert.equal(image.name, 'after'); + assert.equal(layer.ready(), true); +}); diff --git a/src/globe/cesium/maps.js b/src/globe/cesium/maps.js index 6afdd2bb2..bebe94616 100644 --- a/src/globe/cesium/maps.js +++ b/src/globe/cesium/maps.js @@ -66,7 +66,7 @@ const dispose = (value) => { * credits?: object, * loadPhotorealTileset?: (credentials: object) => Promise<{ tileset: object | null, errors: Error[] }>, * }} [options] `tilesetRef` names the startup Google tileset the app added. - * @returns {{ maps: object, destroy: () => void }} + * @returns {{ maps: object, tilesets: () => object[], destroy: () => void }} */ export function createCesiumMaps( viewer, @@ -221,6 +221,8 @@ export function createCesiumMaps( return { maps, + /** Every tileset the map may show (engine.imagery drapes on a shown one). */ + tilesets, destroy() { if (destroyed) return; destroyed = true; diff --git a/src/globe/contract.js b/src/globe/contract.js index ef673a54c..605ae752d 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -513,6 +513,78 @@ * @property {(options: GroundSetOptions) => GroundSet} createSet * @property {() => { id: string, kind: string, count: number, shown: boolean, order: number }[]} list * + * @typedef {{ west: number, south: number, east: number, north: number }} DegreeRectangle + * Degrees; south below north; west > east crosses the antimeridian. + * + * @typedef {object} ImagerySource + * What an imagery layer draws, and where (src/globe/imagery.js checks and + * defaults these). Two kinds: + * - tiles: `{ kind: 'tiles', url, subdomains?, minLevel?, maxLevel?, + * tileSize?, rectangle?, credit?, opaque?, maxRequests? }`, XYZ tiles in + * Web Mercator; `url` holds {z}, {x} and {y}, and {s} for one of + * `subdomains` (['a', 'b', 'c']); levels 0..18 and 256 px tiles by + * default; `opaque` tiles have no alpha (Cesium uploads them as RGB); + * `maxRequests` caps the layer's tile requests in flight (Cesium: + * unbounded by default; Navara loads 6 at a time); + * - image: `{ kind: 'image', image | url, rectangle?, credit? }`, one + * image stretched over the rectangle in longitude and latitude + * (equirectangular, row 0 on the north edge): a canvas, an ImageBitmap + * or image element, `{ width, height, data }` RGBA bytes, or an image + * URL the engine loads. + * `rectangle` (DegreeRectangle) bounds what draws: the globe when omitted + * (Web Mercator tiles stop at ±85.05°). `credit` is HTML, shown while the + * layer is. + * @property {'tiles'|'image'} kind + * + * @typedef {object} ImageryLayerOptions + * A raster layer draped on the ground: on terrain and 3D tiles, following + * their relief, under every ground fill, mover, line and model; colours are + * the image's own, blended by its alpha times the layer's (Cesium: an + * ImageryLayer on the imagery stack of the surface shown). + * @property {string} id Unique among the engine's live imagery layers. + * @property {ImagerySource} source + * @property {number} [alpha] 0..1, 1 by default. + * @property {boolean} [show] Shown when created unless false. + * @property {'none'|'left'|'right'} [split] The side of the split position + * the layer draws on ('none', the default: both). Cesium splits on the + * globe only. + * @property {number} [order] Draw order among imagery layers (higher draws + * later, on top); 0 by default, then creation order. Every imagery layer + * draws above the base imagery. + * + * @typedef {object} ImageryLayer + * @property {string} id + * @property {(source: ImagerySource) => void} setSource Draws `source` in + * place of the last one, at the layer's place in the order; the same + * canvas again draws its new pixels. + * @property {() => object} source The checked source drawn. + * @property {(alpha: number) => void} setAlpha A fade: at 0 the layer draws + * nothing but stays on its surface (Cesium keeps its tiles mapped), so it + * comes back without a gap. + * @property {() => number} alpha + * @property {(visible: boolean) => void} show Shows or hides the layer + * (Cesium unmaps a hidden layer's tiles: hide with show, fade with alpha). + * @property {() => boolean} shown + * @property {(side: 'none'|'left'|'right') => void} setSplit + * @property {() => 'none'|'left'|'right'} split + * @property {() => boolean} ready Whether the source is built and drawn + * from the next frame (an image URL loaded); tiles arrive after it. + * @property {() => void} destroy + * Every call requests a render; nothing animates on its own. + * + * @typedef {object} GlobeImagery + * @property {(options: ImageryLayerOptions) => ImageryLayer} createLayer + * @property {() => { id: string, kind: string, shown: boolean, alpha: number, split: string, order: number }[]} list + * In draw order, bottom first. + * @property {(fraction: number) => void} setSplitPosition Where the split + * runs: a fraction of the canvas width from its left edge (0..1); layers + * split 'left' draw left of it, 'right' right of it. One per engine + * (Cesium's scene.splitPosition). + * @property {() => number} splitPosition 0.5 until set. + * @property {() => 'globe'|'tiles'|'none'} surface Where imagery drapes + * now: the globe, 3D tiles, or nothing (a Cesium stack with neither + * draws no imagery until one shows; the layers wait). + * * @typedef {object} GlobeEngine * @property {'cesium'|'navara'} kind * @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host @@ -537,6 +609,7 @@ * points, models), drawn by the engine. * @property {GlobeLines} lines The layers' dynamic polylines. * @property {GlobeGround} ground The layers' fills on the ground. + * @property {GlobeImagery} imagery The layers' rasters on the ground. * @property {() => void} destroy * @property {() => boolean} isDestroyed True once destroy() has run. * @property {{ cesium?: object, navara?: object }} [extensions] @@ -745,6 +818,13 @@ const SHAPE = { movers: { createSet: isFunction, list: isFunction }, lines: { createLine: isFunction, list: isFunction }, ground: { createSet: isFunction, list: isFunction }, + imagery: { + createLayer: isFunction, + list: isFunction, + setSplitPosition: isFunction, + splitPosition: isFunction, + surface: isFunction, + }, maps: { setBaseImagery: isFunction, setTerrain: isFunction, @@ -785,7 +865,7 @@ 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']; +const REPORTED_REQUIRED = ['movers', 'lines', 'ground', 'imagery']; const memberAt = (engine, path) => path.split('.').reduce((value, key) => value?.[key], engine); @@ -794,8 +874,8 @@ const memberAt = (engine, path) => * Which optional capabilities an engine has: { visualStyles, ... } as * booleans. A caller that refuses a feature on an engine without a Cesium * viewer (voice, say) asks here instead: Cesium draws these through the app's - * own stages and reports false. `movers`, `lines` and `ground` are - * required; they are reported too, true for every valid engine. + * own stages and reports false. `movers`, `lines`, `ground` and `imagery` + * are required; they are reported too, true for every valid engine. */ export function engineCapabilities(engine) { const whole = (members, prefix = '') => diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index 4df2107c0..90f454907 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -328,6 +328,21 @@ test('ground is required and reported as a capability', () => { assert.throws(() => assertGlobeEngine(half), /ground\.list/); }); +test('imagery is required and reported as a capability', () => { + 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, { + waitFrames: async () => {}, + }).map(({ name }) => name); + assert.ok(names.some((name) => name.startsWith('imagery:'))); +}); + test('engineCapabilities names the optional capabilities an engine has whole', () => { const plain = createFakeEngine(); for (const flag of ['visualStyles', 'bloom', 'cloudCover', 'contactSonar']) diff --git a/src/globe/imagery.js b/src/globe/imagery.js new file mode 100644 index 000000000..e778ec1fe --- /dev/null +++ b/src/globe/imagery.js @@ -0,0 +1,442 @@ +// engine.imagery, the engine-neutral part: raster layers draped on the +// ground (terrain and 3D tiles) — XYZ tiles from a URL template, or one +// image (a canvas, a decoded image, RGBA bytes or an image URL) stretched +// over a longitude/latitude rectangle. This checks layer options and +// sources, keeps each layer's look (alpha, shown, split, order) and the +// split position, and tiles a Web Mercator template for the engines 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']); +/** Where imagery drapes now (GlobeImagery.surface). */ +export const IMAGERY_SURFACES = Object.freeze(['globe', 'tiles', 'none']); +/** The latitude Web Mercator tiles stop at. */ +export const WEB_MERCATOR_LIMIT = 85.05112878; + +const isFiniteNumber = (value) => + typeof value === 'number' && Number.isFinite(value); +const isObject = (value) => value != null && typeof value === 'object'; +const isWhole = (value) => Number.isInteger(value) && value >= 0; + +function fail(message) { + throw new TypeError(`Imagery layer: ${message}`); +} + +const unit = (value, label) => { + if (!(isFiniteNumber(value) && value >= 0 && value <= 1)) + fail(`${label} must be a number in 0..1: ${value}`); + return value; +}; + +const splitOf = (value = 'none') => { + if (!IMAGERY_SPLITS.includes(value)) + fail(`split must be one of ${IMAGERY_SPLITS.join(', ')}: ${value}`); + return value; +}; + +/** + * A rectangle in degrees, checked: { west, south, east, north } with south + * below north; west > east crosses the antimeridian. Missing: the globe. + */ +function rectangleOf(rectangle) { + if (rectangle == null) + return Object.freeze({ west: -180, south: -90, east: 180, north: 90 }); + if (!isObject(rectangle)) fail('rectangle must be an object'); + const { west, south, east, north } = rectangle; + if ( + ![west, south, east, north].every(isFiniteNumber) || + south >= north || + south < -90 || + north > 90 || + west < -180 || + west > 180 || + east < -180 || + east > 180 || + west === east + ) + fail('rectangle must be { west, south, east, north } in degrees'); + return Object.freeze({ west, south, east, north }); +} + +// What an image source can hold: a canvas, an ImageBitmap or image element +// (anything with a width and a height), or { width, height, data } RGBA bytes. +function imageOf(image) { + if (!isObject(image) && typeof image !== 'function') + fail('image must be a canvas, an image or { width, height, data }'); + const { width, height } = image; + if (!(isWhole(width) && width > 0 && isWhole(height) && height > 0)) + fail(`image must have a positive width and height: ${width}×${height}`); + if ( + image.data !== undefined && + (!ArrayBuffer.isView(image.data) || + image.data.length !== width * height * 4) + ) + fail('image data must be width × height × 4 RGBA bytes'); + return image; +} + +/** Whether an image source holds RGBA bytes rather than a drawable image. */ +export const isPixelImage = (image) => ArrayBuffer.isView(image?.data); + +/** + * An imagery source, checked, as the engines draw it: + * - { kind: 'tiles', url, subdomains, minLevel, maxLevel, tileSize, + * rectangle, credit, opaque, maxRequests }: XYZ tiles in Web Mercator; + * `url` holds {z}, {x} and {y}, and {s} for one of `subdomains`; + * - { kind: 'image', image | url, rectangle, credit }: one image over the + * rectangle, equirectangular (row 0 on the north edge). + * The rectangle is in degrees (the globe when omitted); `credit` is HTML. + */ +export function normalizeImagerySource(source) { + if (!isObject(source)) fail('source must be an object'); + const { kind } = source; + if (!IMAGERY_SOURCE_KINDS.includes(kind)) + fail( + `source kind must be one of ${IMAGERY_SOURCE_KINDS.join(', ')}: ${kind}`, + ); + const rectangle = rectangleOf(source.rectangle); + const credit = source.credit ?? null; + if (credit !== null && typeof credit !== 'string') + fail('credit must be a string of HTML'); + if (kind === 'image') { + const hasImage = source.image != null; + const hasUrl = source.url != null; + if (hasImage === hasUrl) fail('an image source needs an image or a url'); + if (hasUrl && (typeof source.url !== 'string' || !source.url)) + fail('url must be a non-empty string'); + return Object.freeze({ + kind, + image: hasImage ? imageOf(source.image) : null, + url: hasUrl ? source.url : null, + rectangle, + credit, + }); + } + const { url } = source; + if ( + typeof url !== 'string' || + !['{z}', '{x}', '{y}'].every((key) => url.includes(key)) + ) + fail('a tiles url must hold {z}, {x} and {y}'); + const subdomains = source.subdomains ?? ['a', 'b', 'c']; + if ( + !Array.isArray(subdomains) || + !subdomains.length || + !subdomains.every((name) => typeof name === 'string') + ) + fail('subdomains must be a list of strings'); + const minLevel = source.minLevel ?? 0; + const maxLevel = source.maxLevel ?? 18; + if (!(isWhole(minLevel) && isWhole(maxLevel) && minLevel <= maxLevel)) + fail(`levels must be whole numbers, min <= max: ${minLevel}..${maxLevel}`); + const tileSize = source.tileSize ?? 256; + if (!(isWhole(tileSize) && tileSize > 0)) + fail(`tileSize must be a positive whole number: ${tileSize}`); + const maxRequests = source.maxRequests ?? null; + if (maxRequests !== null && !(isWhole(maxRequests) && maxRequests > 0)) + fail(`maxRequests must be a positive whole number: ${maxRequests}`); + if (source.opaque !== undefined && typeof source.opaque !== 'boolean') + fail('opaque must be a boolean'); + return Object.freeze({ + kind, + url, + subdomains: Object.freeze([...subdomains]), + minLevel, + maxLevel, + tileSize, + rectangle, + credit, + opaque: source.opaque === true, + maxRequests, + }); +} + +/** createLayer options with defaults, or a TypeError. */ +export function normalizeImageryLayerOptions(options) { + if (!isObject(options)) fail('options must be an object'); + const { id } = options; + if (typeof id !== 'string' || !id) fail('id must be a non-empty string'); + const order = options.order ?? 0; + if (!isFiniteNumber(order)) fail(`order must be a number: ${order}`); + if (options.show !== undefined && typeof options.show !== 'boolean') + fail('show must be a boolean'); + return { + id, + order, + source: normalizeImagerySource(options.source), + alpha: unit(options.alpha ?? 1, 'alpha'), + shown: options.show !== false, + split: splitOf(options.split), + }; +} + +/** A whole-globe rectangle wraps across the antimeridian. */ +export const wrapsGlobe = ({ west, east }) => west === -180 && east === 180; + +/** + * engine.imagery over an engine's drawing: `build(state)` returns the + * engine part of one layer ({ source(), look(), destroy(), ready?() }), + * called with the layer's checked state ({ options, source, alpha, shown, + * split, seq }) after it is stored; `source()` follows a new source and + * `look()` a new alpha, show or split. `splitPosition(fraction)` draws the + * engine's split, `surface()` answers GlobeImagery.surface. Layer ids are + * unique while the layer lives; destroyAll() is for the engine's destroy. + * `initialSplit` is the split position before one is set; `readSplit`, + * when given, reads the one drawn (an engine whose split others set too). + */ +export function createImageryRegistry( + build, + { + splitPosition = () => {}, + surface = () => 'globe', + initialSplit = 0.5, + readSplit = null, + } = {}, +) { + const live = new Map(); + let split = initialSplit; + let seq = 0; + const imagery = { + createLayer(input) { + const options = normalizeImageryLayerOptions(input); + if (live.has(options.id)) + throw new Error(`Imagery layer ${options.id} already exists`); + const state = { + options, + source: options.source, + alpha: options.alpha, + shown: options.shown, + split: options.split, + seq: seq++, + destroyed: false, + }; + live.set(options.id, state); + let part; + try { + part = build(state); + } catch (error) { + live.delete(options.id); + throw error; + } + const alive = () => { + if (state.destroyed) throw new Error('Imagery layer was destroyed'); + }; + const restyle = (key, value) => { + alive(); + if (state[key] === value) return; + state[key] = value; + part.look(); + }; + const layer = { + id: options.id, + setSource(next) { + alive(); + state.source = normalizeImagerySource(next); + part.source(); + }, + source: () => state.source, + setAlpha: (alpha) => restyle('alpha', unit(alpha, 'alpha')), + alpha: () => state.alpha, + show(visible) { + if (typeof visible !== 'boolean') fail('show takes a boolean'); + restyle('shown', visible); + }, + shown: () => state.shown, + setSplit: (side) => restyle('split', splitOf(side)), + split: () => state.split, + ready: () => + !state.destroyed && (part.ready ? part.ready() === true : true), + destroy() { + if (state.destroyed) return; + state.destroyed = true; + live.delete(options.id); + part.destroy(); + }, + isDestroyed: () => state.destroyed, + }; + state.layer = layer; + return layer; + }, + list: () => + [...live.values()] + .sort((a, b) => a.options.order - b.options.order || a.seq - b.seq) + .map(({ options, source, shown, alpha, split: side }) => ({ + id: options.id, + kind: source.kind, + shown, + alpha, + split: side, + order: options.order, + })), + setSplitPosition(fraction) { + unit(fraction, 'split position'); + if (fraction === imagery.splitPosition()) return; + split = fraction; + splitPosition(fraction); + }, + splitPosition: () => readSplit?.() ?? split, + surface, + }; + return { + imagery, + live, + /** The live layers in draw order: lowest order first, then creation. */ + ordered: () => + [...live.values()].sort( + (a, b) => a.options.order - b.options.order || a.seq - b.seq, + ), + destroyAll() { + for (const state of [...live.values()]) state.layer.destroy(); + }, + }; +} + +// Web Mercator tiles of a template, for the engines that mosaic them +// (Navara draws one atlas per tiles layer). + +const MAX_TILE_SIDE = 8; +export const mercatorX = (lon) => (lon + 180) / 360; +export const mercatorY = (lat) => { + const r = + (Math.max(-WEB_MERCATOR_LIMIT, Math.min(WEB_MERCATOR_LIMIT, lat)) * + Math.PI) / + 180; + return (1 - Math.log(Math.tan(Math.PI / 4 + r / 2)) / Math.PI) / 2; +}; + +/** + * The tiles over `box` (degrees) that a view `rectangle` (radians, or null: + * all of the box) shows at `level`, or the level below until they are at + * most 8×8: { key, z, x, y, width, height, bounds: [x, y, w, h] in Web + * Mercator units }, or null when the view misses the box. Never a global + * mosaic. + */ +export function imageryTilePlan(box, rectangle, level) { + let area = { ...box }; + if (rectangle && rectangle.east >= rectangle.west) { + const degrees = 180 / Math.PI; + area = { + west: Math.max(box.west, rectangle.west * degrees), + east: Math.min(box.east, rectangle.east * degrees), + south: Math.max(box.south, rectangle.south * degrees), + north: Math.min(box.north, rectangle.north * degrees), + }; + } + if (area.west >= area.east || area.south >= area.north) return null; + for (let z = Math.max(0, Math.floor(level)); z >= 0; z--) { + const n = 2 ** z; + const x = Math.max(0, Math.floor(mercatorX(area.west) * n)); + const y = Math.max(0, Math.floor(mercatorY(area.north) * n)); + const right = Math.min(n - 1, Math.ceil(mercatorX(area.east) * n) - 1); + const bottom = Math.min(n - 1, Math.ceil(mercatorY(area.south) * n) - 1); + const width = right - x + 1, + height = bottom - y + 1; + if (width <= MAX_TILE_SIDE && height <= MAX_TILE_SIDE) + return { + key: [z, x, y, width, height].join('/'), + z, + x, + y, + width, + height, + bounds: [x / n, y / n, width / n, height / n], + }; + } + return null; +} + +/** One tile's URL: {z}/{x}/{y}, and {s} picked by the tile from `subdomains`. */ +export function imageryTileUrl( + template, + x, + y, + z, + subdomains = ['a', 'b', 'c'], +) { + return template + .replaceAll('{x}', String(x)) + .replaceAll('{y}', String(y)) + .replaceAll('{z}', String(z)) + .replaceAll('{s}', subdomains[(x + y) % subdomains.length]); +} + +/** + * The tile level whose texels match the ground under a camera `height` m + * up at `latitude` (radians), seen with a vertical field of view `fovy` + * over `heightPx` CSS pixels. + */ +export function imageryTileLevel({ height, latitude, fovy, heightPx }) { + const metresPerPixel = + (2 * Math.max(1, height) * Math.tan(fovy / 2)) / Math.max(1, heightPx); + return Math.ceil( + Math.log2((156543.033928 * Math.cos(latitude)) / metresPerPixel), + ); +} + +/** + * Tile loads `limit` at a time. A task is { url, controllers, live(), + * draw(image), fail(error) }; one no longer live is skipped, and an image + * that arrives for one is closed without drawing. Cancellation holds its + * admission slot until fetch AND decode settle. + */ +export function createImageryTileQueue({ load, limit = 6 }) { + let active = 0, + peak = 0, + destroyed = false; + const pending = [], + controllers = new Set(); + function pump() { + while (!destroyed && active < limit && pending.length) { + const task = pending.shift(); + if (!task.live()) continue; + const controller = new AbortController(); + controllers.add(controller); + task.controllers.add(controller); + active++; + peak = Math.max(peak, active); + Promise.resolve() + .then(() => load(task.url, controller.signal)) + .then((image) => { + try { + if (!destroyed && !controller.signal.aborted && task.live()) + task.draw(image); + } finally { + image?.close?.(); + } + }) + .catch((error) => { + if (!destroyed && !controller.signal.aborted && task.live()) + task.fail(error); + }) + .finally(() => { + controllers.delete(controller); + task.controllers.delete(controller); + active--; + pump(); + }); + } + } + return { + add(tasks) { + if (!destroyed) { + pending.push(...tasks); + pump(); + } + }, + prune() { + 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; + for (const c of controllers) c.abort(); + }, + }; +} diff --git a/src/globe/imagery.test.mjs b/src/globe/imagery.test.mjs new file mode 100644 index 000000000..24599c31d --- /dev/null +++ b/src/globe/imagery.test.mjs @@ -0,0 +1,259 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createImageryRegistry, + createImageryTileQueue, + imageryTileLevel, + imageryTilePlan, + imageryTileUrl, + mercatorX, + mercatorY, + normalizeImageryLayerOptions, + normalizeImagerySource, +} from './imagery.js'; + +const image = { width: 2, height: 1, data: new Uint8Array(8) }; + +test('sources are checked and defaulted; rectangles default to the globe', () => { + const tiles = normalizeImagerySource({ + kind: 'tiles', + url: 'https://t.example/{z}/{x}/{y}.png', + }); + assert.deepEqual( + { ...tiles }, + { + kind: 'tiles', + url: 'https://t.example/{z}/{x}/{y}.png', + subdomains: ['a', 'b', 'c'], + minLevel: 0, + maxLevel: 18, + tileSize: 256, + rectangle: { west: -180, south: -90, east: 180, north: 90 }, + credit: null, + opaque: false, + maxRequests: null, + }, + ); + const across = normalizeImagerySource({ + kind: 'image', + image, + rectangle: { west: 170, south: -10, east: -170, north: 10 }, + credit: 'x', + }); + assert.equal(across.image, image); + assert.equal(across.rectangle.west, 170, 'west > east crosses 180°'); + assert.equal( + normalizeImagerySource({ kind: 'image', url: 'a.png' }).url, + 'a.png', + ); + for (const bad of [ + null, + { kind: 'wms' }, + { kind: 'tiles', url: 'https://t.example/{z}/{x}.png' }, + { + kind: 'tiles', + url: 'https://t.example/{z}/{x}/{y}', + minLevel: 4, + maxLevel: 2, + }, + { kind: 'tiles', url: 'https://t.example/{z}/{x}/{y}', maxRequests: 0 }, + { kind: 'tiles', url: 'https://t.example/{z}/{x}/{y}', subdomains: [] }, + { kind: 'image' }, + { kind: 'image', image, url: 'a.png' }, + { kind: 'image', image: { width: 0, height: 1 } }, + { kind: 'image', image: { width: 2, height: 2, data: new Uint8Array(4) } }, + { + kind: 'image', + image, + rectangle: { west: 0, south: 1, east: 2, north: 1 }, + }, + { + kind: 'image', + image, + rectangle: { west: 0, south: 0, east: 0, north: 1 }, + }, + { kind: 'image', image, credit: 7 }, + ]) + assert.throws( + () => normalizeImagerySource(bad), + TypeError, + JSON.stringify(bad), + ); +}); + +test('layer options are checked and defaulted', () => { + const options = normalizeImageryLayerOptions({ + id: 'a', + source: { kind: 'image', image }, + }); + assert.deepEqual( + [options.order, options.alpha, options.shown, options.split], + [0, 1, true, 'none'], + ); + for (const bad of [ + { source: { kind: 'image', image } }, + { id: 'a' }, + { id: 'a', source: { kind: 'image', image }, alpha: 1.5 }, + { id: 'a', source: { kind: 'image', image }, show: 1 }, + { id: 'a', source: { kind: 'image', image }, split: 'top' }, + { id: 'a', source: { kind: 'image', image }, order: Infinity }, + ]) + assert.throws(() => normalizeImageryLayerOptions(bad), TypeError); +}); + +test('the registry keeps each layer’s look, draws in order and calls its part', () => { + const calls = []; + const { imagery, ordered, destroyAll } = createImageryRegistry( + (state) => ({ + source: () => calls.push(['source', state.options.id, state.source.kind]), + look: () => + calls.push([ + 'look', + state.options.id, + state.alpha, + state.shown, + state.split, + ]), + destroy: () => calls.push(['destroy', state.options.id]), + }), + { + splitPosition: (fraction) => calls.push(['split', fraction]), + surface: () => 'tiles', + }, + ); + const top = imagery.createLayer({ + id: 'top', + source: { kind: 'image', image }, + order: 1, + }); + const bottom = imagery.createLayer({ + id: 'bottom', + source: { kind: 'image', image }, + }); + assert.throws( + () => imagery.createLayer({ id: 'top', source: { kind: 'image', image } }), + /already exists/, + ); + assert.deepEqual( + ordered().map(({ options }) => options.id), + ['bottom', 'top'], + ); + bottom.setAlpha(0.5); + bottom.setAlpha(0.5); + bottom.show(false); + bottom.setSplit('left'); + top.setSource({ kind: 'tiles', url: 'https://t.example/{z}/{x}/{y}' }); + imagery.setSplitPosition(0.25); + imagery.setSplitPosition(0.25); + assert.equal(imagery.surface(), 'tiles'); + assert.deepEqual(calls, [ + ['look', 'bottom', 0.5, true, 'none'], + ['look', 'bottom', 0.5, false, 'none'], + ['look', 'bottom', 0.5, false, 'left'], + ['source', 'top', 'tiles'], + ['split', 0.25], + ]); + assert.throws(() => bottom.show('no'), TypeError); + assert.throws(() => top.setSource({ kind: 'tiles' }), TypeError); + assert.equal(top.source().kind, 'tiles', 'a refused source changes nothing'); + destroyAll(); + assert.deepEqual(imagery.list(), []); + assert.deepEqual(calls.slice(-2), [ + ['destroy', 'top'], + ['destroy', 'bottom'], + ]); + assert.equal(top.isDestroyed(), true); + assert.throws(() => top.setAlpha(1), /destroyed/); +}); + +test('the tile level matches the ground resolution under the camera', () => { + // About 38 m per pixel at the equator: level 12 (38.2 m at 256 px). + const level = imageryTileLevel({ + height: 18_000, + latitude: 0, + fovy: Math.PI / 3, + heightPx: 540, + }); + assert.equal(level, 12); +}); + +test('visible imagery tiles stay bounded and contain the intersected geographic area', () => { + const box = { west: -100, east: -90, south: 25, north: 35 }; + for (const level of [0, 5, 9, 12]) { + const plan = imageryTilePlan(box, null, level), + n = 2 ** plan.z; + assert.ok(plan.width <= 8 && plan.height <= 8); + assert.ok( + plan.x <= mercatorX(box.west) * n && + plan.x + plan.width >= mercatorX(box.east) * n, + ); + assert.ok( + plan.y <= mercatorY(box.north) * n && + plan.y + plan.height >= mercatorY(box.south) * n, + ); + } + const r = (x) => (x * Math.PI) / 180; + const view = { + west: r(-97.75), + east: r(-97.73), + south: r(30.26), + north: r(30.28), + }; + const plan = imageryTilePlan(box, view, 12); + assert.equal(plan.z, 12); + assert.ok(plan.width * plan.height < 8); + assert.equal( + imageryTilePlan(box, { west: 0, east: 0.1, south: 0, north: 0.1 }, 12), + null, + ); +}); + +test('source tile addressing retains XYZ order and deterministic subdomain', () => { + assert.equal( + imageryTileUrl('https://gibs-{s}.example/{z}/{y}/{x}.png', 4, 7, 9), + 'https://gibs-c.example/9/7/4.png', + ); +}); + +test('six admissions include slow aborted decodes; stale images close without drawing or errors', async () => { + const pending = [], + drawn = [], + failed = [], + closed = []; + let live = true; + const controllers = new Set(); + const queue = createImageryTileQueue({ + load: (url, signal) => + new Promise((resolve) => pending.push({ url, signal, resolve })), + }); + const task = (id) => ({ + url: String(id), + controllers, + live: () => live, + draw: () => drawn.push(id), + fail: () => failed.push(id), + }); + 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); + 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); + for (const p of pending) p.resolve({ close: () => closed.push(p.url) }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(pending.length, 7); + assert.equal(drawn.length, 0); + assert.equal(closed.length, 6); + queue.destroy(); + replacement = false; + pending[6].resolve({ close: () => closed.push('last') }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(drawn, []); + assert.deepEqual(failed, []); + assert.equal(closed.length, 7); + assert.deepEqual(queue.diagnostics(), { active: 0, queued: 0, peak: 6 }); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 2c8318947..e77b5ea62 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -54,6 +54,15 @@ const NO_GROUND = Object.freeze({ }, 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. @@ -73,6 +82,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 }, * container?: HTMLElement, * fetch?: typeof fetch, * clock?: object, @@ -97,7 +107,9 @@ function readCamera(raw) { * `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 - * destroyed with the engine; + * 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; * `fetch` and `clock` (the camera model's timers) are test seams. * @returns {import('../contract.js').GlobeEngine} */ @@ -112,6 +124,7 @@ export function createNavaraEngine( movers: moversPort = null, lines: linesPort = null, ground: groundPort = null, + imagery: imageryPort = null, sharpen: sharpenPort = null, styles: stylesPort = null, bloom: bloomPort = null, @@ -223,6 +236,13 @@ export function createNavaraEngine( }); const credits = createNavaraCredits(view); const maps = createNavaraMaps(view, fetch ? { fetch } : {}); + const imagery = + imageryPort?.({ + camera: model.camera, + size, + credits: credits.credits, + photorealActive: () => maps.maps.photorealActive(), + }) ?? null; const { surface } = createNavaraSurface(view, hooks); // Navara's controller zooms through the ground; keep the camera above it. // With the camera inputs off nothing zooms, and the app places the camera @@ -414,6 +434,7 @@ export function createNavaraEngine( movers: moversPort?.movers ?? NO_MOVERS, lines: linesPort?.lines ?? NO_LINES, ground: groundPort?.ground ?? NO_GROUND, + imagery: imagery?.imagery ?? NO_IMAGERY, destroy() { if (destroyed) return; destroyed = true; @@ -424,6 +445,7 @@ export function createNavaraEngine( input.destroy(); removeGroundPicks(); picking.destroy(); + imagery?.destroy(); credits.destroy(); maps.destroy(); sharpenPort?.destroy(); @@ -450,6 +472,8 @@ 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: { diff --git a/src/globe/navara/ground/pass.js b/src/globe/navara/ground/pass.js index 9a5a4203f..da16e82ce 100644 --- a/src/globe/navara/ground/pass.js +++ b/src/globe/navara/ground/pass.js @@ -230,7 +230,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) { 'gevEarthquakeGround', 'gevObservedWeather', 'gevNepalEvent', - 'gevRecentImagery', + 'gevImagery', 'toneMapping', ]; static insertBefore = [ diff --git a/src/globe/navara/imagery/index.js b/src/globe/navara/imagery/index.js new file mode 100644 index 000000000..ca8f8aab3 --- /dev/null +++ b/src/globe/navara/imagery/index.js @@ -0,0 +1,444 @@ +// engine.imagery on Navara: every shown layer in one screen pass (pass.js), +// classified on the scene depth, so rasters drape on terrain and 3D tiles +// alike. An image source is one texture (mipmapped, wrapping across 180° +// when it spans the globe); setting the same canvas again uploads its new +// pixels. A tiles source is an atlas of at most 8×8 Web Mercator tiles over +// the part of its rectangle in view, at the level the camera's height asks +// for, rebuilt when the camera moves to another plan and filled as tiles +// arrive (6 loads at a time, or the source's maxRequests). A layer's credit +// shows while it does. Every change composes the drawn layers (bottom +// first) and asks for one frame; nothing animates on its own. + +import { + CanvasTexture, + ClampToEdgeWrapping, + DataTexture, + LinearFilter, + LinearMipmapLinearFilter, + RepeatWrapping, + RGBAFormat, + Texture, + UnsignedByteType, +} from 'three'; +import { + WEB_MERCATOR_LIMIT, + createImageryRegistry, + createImageryTileQueue, + imageryTileLevel, + imageryTilePlan, + imageryTileUrl, + isPixelImage, + wrapsGlobe, +} from '../../imagery.js'; +/** Layers one frame of the pass draws (pass.js samples one texture each). */ +export const MAX_IMAGERY_SLOTS = 8; + +const SIDE = { none: 0, left: -1, right: 1 }; + +async function loadTile(url, signal) { + const response = await fetch(url, { signal }); + if (!response.ok) throw new Error(`Imagery tile HTTP ${response.status}`); + const blob = await response.blob(); + signal.throwIfAborted(); + return createImageBitmap(blob); +} + +const defaultCanvas = (width, height) => + Object.assign(document.createElement('canvas'), { width, height }); + +// Linear between texels, mipmapped, row 0 at v = 0 (no flip). +function finish(texture, wrap) { + texture.flipY = false; + texture.magFilter = LinearFilter; + texture.minFilter = LinearMipmapLinearFilter; + texture.generateMipmaps = true; + texture.wrapS = wrap ? RepeatWrapping : ClampToEdgeWrapping; + texture.wrapT = ClampToEdgeWrapping; + texture.needsUpdate = true; + return texture; +} + +// An image source's texture. RGBA bytes are read as they are (sRGB bytes, +// blended in display space), as canvases and images are. +function imageTexture(image, wrap) { + if (isPixelImage(image)) + return finish( + new DataTexture( + image.data, + image.width, + image.height, + RGBAFormat, + UnsignedByteType, + ), + wrap, + ); + return finish( + typeof image.getContext === 'function' + ? new CanvasTexture(image) + : new Texture(image), + wrap, + ); +} + +// Degrees, east past west (an antimeridian crossing adds 360°). +const unwrapped = ({ west, south, east, north }) => [ + west, + south, + east <= west ? east + 360 : east, + north, +]; + +/** + * @param {object} view an initialised Navara ThreeView + * @param {{ camera: object, size: () => { width: number, height: number }, + * credits: { addStatic: (html: string) => () => void }, + * photorealActive: () => boolean, + * createPass: (view: object) => ReturnType, + * load?: (url: string, signal: AbortSignal) => Promise, + * createCanvas?: (width: number, height: number) => object }} options + * The engine's camera, size and credits; `createPass` makes the pass + * (pass.js createImageryPass; the view's three runtime, which Node tests + * 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 }} + */ +export function createNavaraImagery( + view, + { + camera, + size, + credits, + photorealActive, + createPass, + load = loadTile, + createCanvas = defaultCanvas, + }, +) { + const pass = createPass(view); + const entries = new Map(); + let warned = false; + + const compose = () => { + const drawn = registry + .ordered() + .map((state) => entries.get(state)) + .filter( + (entry) => entry?.state.shown && entry.state.alpha > 0 && entry.slot(), + ); + if (drawn.length > MAX_IMAGERY_SLOTS && !warned) { + warned = true; + console.warn( + `[Imagery] Navara draws the top ${MAX_IMAGERY_SLOTS} of ${drawn.length} layers`, + ); + } + pass.set( + drawn.slice(-MAX_IMAGERY_SLOTS).map((entry) => entry.slot()), + registry.imagery.splitPosition(), + ); + view.forceUpdate(); + }; + + // A layer's credit shows while the layer does. + function syncCredit(entry) { + const credit = entry.state.shown ? entry.state.source.credit : null; + if (credit === entry.credit) return; + entry.releaseCredit?.(); + entry.credit = credit; + entry.releaseCredit = credit ? credits.addStatic(credit) : null; + } + + function imageEntry(entry) { + let texture = null, + image = null, + loading = null; + const drop = () => { + texture?.dispose(); + texture = null; + }; + const show = (next, source) => { + const wrapS = wrapsGlobe(source.rectangle) + ? RepeatWrapping + : ClampToEdgeWrapping; + if (texture && image === next && texture.wrapS === wrapS) { + // The same canvas or bytes again: their new pixels. + texture.needsUpdate = true; + } else { + drop(); + texture = imageTexture(next, wrapS === RepeatWrapping); + } + image = next; + }; + return { + source() { + const { source } = entry.state; + loading?.abort(); + loading = null; + if (source.image) { + show(source.image, source); + return; + } + drop(); + image = null; + const controller = new AbortController(); + loading = controller; + Promise.resolve() + .then(() => load(source.url, controller.signal)) + .then( + (decoded) => { + if (controller.signal.aborted || entry.state.destroyed) { + decoded?.close?.(); + return; + } + loading = null; + show(decoded, source); + compose(); + }, + (error) => { + if (controller.signal.aborted) return; + loading = null; + console.warn('[Imagery] image failed to load:', error); + }, + ); + }, + slot() { + if (!texture) return null; + const rect = unwrapped(entry.state.source.rectangle); + return { + texture, + rect, + bounds: rect, + look: [entry.state.alpha, SIDE[entry.state.split], 0, 0], + }; + }, + ready: () => texture !== null, + cameraChanged: () => false, + destroy() { + loading?.abort(); + loading = null; + drop(); + image = null; + }, + diagnostics: () => ({ kind: 'image', loaded: texture !== null }), + }; + } + + function tilesEntry(entry) { + let atlas = null, + queue = null; + const disposeAtlas = () => { + const current = atlas; + atlas = null; + if (!current) return; + for (const controller of current.controllers) controller.abort(); + current.texture.dispose(); + current.canvas.width = current.canvas.height = 0; + queue?.prune(); + }; + // The source's rectangle within Web Mercator's latitudes; across the + // antimeridian the atlas covers every longitude. + const box = () => { + const { west, south, east, north } = entry.state.source.rectangle; + return { + west: west < east ? west : -180, + east: west < east ? east : 180, + south: Math.max(south, -WEB_MERCATOR_LIMIT), + north: Math.min(north, WEB_MERCATOR_LIMIT), + }; + }; + // The atlas for the view; true when it changed. + function update() { + if (!entry.state.shown) { + if (!atlas) return false; + disposeAtlas(); + return true; + } + const source = entry.state.source; + let pose; + try { + pose = camera.pose(); + } catch { + // Before the first frame the camera has no pose yet. + return false; + } + const level = Math.max( + source.minLevel, + Math.min( + source.maxLevel, + imageryTileLevel({ + height: pose.cartographic.height, + latitude: pose.cartographic.latitude, + fovy: pose.fovy, + heightPx: size().height, + }), + ), + ); + const plan = imageryTilePlan(box(), camera.viewRectangle(), level); + if ((atlas?.plan.key ?? null) === (plan?.key ?? null)) return false; + disposeAtlas(); + if (!plan) return true; + const tile = source.tileSize; + const canvas = createCanvas(plan.width * tile, plan.height * tile); + const texture = finish(new CanvasTexture(canvas), false); + const current = { + plan, + canvas, + texture, + controllers: new Set(), + loaded: 0, + failed: 0, + }; + atlas = current; + const context = canvas.getContext('2d'); + const tasks = []; + for (let y = 0; y < plan.height; y++) + for (let x = 0; x < plan.width; x++) + tasks.push({ + url: imageryTileUrl( + source.url, + plan.x + x, + plan.y + y, + plan.z, + source.subdomains, + ), + controllers: current.controllers, + live: () => !entry.state.destroyed && atlas === current, + draw(image) { + context.drawImage(image, x * tile, y * tile, tile, tile); + current.loaded++; + texture.needsUpdate = true; + view.forceUpdate(); + }, + fail() { + current.failed++; + }, + }); + queue.add(tasks); + return true; + } + return { + source() { + queue?.destroy(); + disposeAtlas(); + queue = createImageryTileQueue({ + load, + limit: entry.state.source.maxRequests ?? 6, + }); + update(); + }, + slot() { + if (!atlas) return null; + const [west, south, east, north] = unwrapped( + entry.state.source.rectangle, + ); + return { + texture: atlas.texture, + rect: [ + west, + Math.max(south, -WEB_MERCATOR_LIMIT), + east, + Math.min(north, WEB_MERCATOR_LIMIT), + ], + bounds: atlas.plan.bounds, + look: [entry.state.alpha, SIDE[entry.state.split], 1, 0], + }; + }, + ready: () => true, + cameraChanged: update, + look: update, + destroy() { + queue?.destroy(); + queue = null; + disposeAtlas(); + }, + diagnostics: () => ({ + kind: 'tiles', + ...(queue?.diagnostics() ?? {}), + atlas: atlas + ? { + level: atlas.plan.z, + tiles: atlas.plan.width * atlas.plan.height, + loaded: atlas.loaded, + failed: atlas.failed, + } + : null, + }), + }; + } + + // One drawing per source kind; a new kind replaces it. + function drawingFor(entry) { + const kind = entry.state.source.kind; + if (entry.drawing?.kind === kind) return entry.drawing; + entry.drawing?.destroy(); + entry.drawing = kind === 'image' ? imageEntry(entry) : tilesEntry(entry); + entry.drawing.kind = kind; + return entry.drawing; + } + + const registry = createImageryRegistry( + (state) => { + const entry = { + state, + drawing: null, + credit: null, + releaseCredit: null, + slot: () => entry.drawing?.slot() ?? null, + }; + entries.set(state, entry); + drawingFor(entry).source(); + syncCredit(entry); + compose(); + return { + source() { + drawingFor(entry).source(); + syncCredit(entry); + compose(); + }, + look() { + entry.drawing.look?.(); + syncCredit(entry); + compose(); + }, + ready: () => entry.drawing.ready(), + destroy() { + entries.delete(state); + entry.drawing.destroy(); + entry.releaseCredit?.(); + entry.releaseCredit = null; + compose(); + }, + }; + }, + { + splitPosition: () => compose(), + surface: () => (photorealActive() ? 'tiles' : 'globe'), + }, + ); + + const offCamera = camera.changed.addEventListener(() => { + let changed = false; + for (const entry of entries.values()) + changed = entry.drawing.cameraChanged() || changed; + if (changed) compose(); + }); + + 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(); + pass.destroy(); + }, + }; +} diff --git a/src/globe/navara/imagery/index.test.mjs b/src/globe/navara/imagery/index.test.mjs new file mode 100644 index 000000000..c3476eb12 --- /dev/null +++ b/src/globe/navara/imagery/index.test.mjs @@ -0,0 +1,200 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createEngineEvent } from '../../contract.js'; +import { MAX_IMAGERY_SLOTS, createNavaraImagery } from './index.js'; + +const BOX = { west: -97.77, south: 30.25, east: -97.71, north: 30.29 }; +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +function fixture() { + const canvases = []; + const draws = []; + const jobs = []; + const sets = []; + const credits = new Map(); + const changed = createEngineEvent(); + let photoreal = false; + let frames = 0; + const view = { + forceUpdate: () => frames++, + }; + const { imagery, diagnostics, destroy } = createNavaraImagery(view, { + camera: { + changed, + pose: () => ({ + cartographic: { height: 12_000, latitude: 0.53 }, + fovy: 0.7, + }), + viewRectangle: () => null, + }, + size: () => ({ width: 1280, height: 800 }), + credits: { + addStatic(html) { + credits.set(html, (credits.get(html) ?? 0) + 1); + return () => credits.set(html, credits.get(html) - 1); + }, + }, + photorealActive: () => photoreal, + createPass: () => ({ + set: (slots, split) => sets.push({ slots, split }), + destroy: () => sets.push('destroyed'), + }), + load: (url, signal) => + new Promise((resolve) => jobs.push({ url, signal, resolve })), + createCanvas(width, height) { + const canvas = { + width, + height, + getContext: () => ({ drawImage: (image) => draws.push(image) }), + }; + canvases.push(canvas); + return canvas; + }, + }); + return { + imagery, + diagnostics, + destroy, + canvases, + draws, + jobs, + credits, + changed, + last: () => sets.filter((entry) => entry !== 'destroyed').at(-1), + sets, + frames: () => frames, + setPhotoreal: (value) => (photoreal = value), + }; +} + +const tiles = (extra = {}) => ({ + kind: 'tiles', + url: 'https://gibs-{s}.example/{z}/{y}/{x}.png', + maxLevel: 12, + rectangle: BOX, + credit: 'NASA GIBS', + ...extra, +}); + +test('a tiles layer mosaics the visible tiles into one atlas; a new source drops it and its loads', async () => { + 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'); + assert.match( + f.jobs[0].url, + /^https:\/\/gibs-[abc]\.example\/12\/\d+\/\d+\.png$/, + ); + const [slot] = f.last().slots; + assert.deepEqual(slot.look, [1, 0, 1, 0], 'alpha 1, no side, Web Mercator'); + assert.deepEqual(slot.rect, [BOX.west, BOX.south, BOX.east, BOX.north]); + assert.equal(f.credits.get('NASA GIBS'), 1); + // A tile arriving draws into the atlas and asks for a frame. + const before = f.frames(); + f.jobs[0].resolve({ close() {} }); + await tick(); + assert.equal(f.draws.length, 1); + assert.ok(f.frames() > before); + // A restyle keeps the atlas; the split side reaches the pass. + const texture = slot.texture; + layer.setAlpha(0.4); + layer.setSplit('left'); + assert.equal(f.last().slots[0].texture, texture); + assert.deepEqual(f.last().slots[0].look, [0.4, -1, 1, 0]); + // Another day: the atlas goes, its pending loads abort, late images close. + let disposed = 0; + texture.addEventListener('dispose', () => disposed++); + const pending = f.jobs.slice(1); + layer.setSource(tiles({ url: 'https://other.example/{z}/{x}/{y}.png' })); + assert.equal(disposed, 1); + assert.ok(pending.every((job) => job.signal.aborted)); + assert.equal(f.canvases[0].width, 0, 'the old atlas canvas is released'); + let closed = 0; + for (const job of pending) job.resolve({ close: () => closed++ }); + await tick(); + assert.equal(closed, pending.length); + assert.equal(f.draws.length, 1, 'late images never draw'); + assert.match(f.jobs.at(-1).url, /^https:\/\/other\.example\//); + layer.destroy(); + assert.equal(f.credits.get('NASA GIBS'), 0); + assert.deepEqual(f.last().slots, []); + f.destroy(); + assert.equal(f.sets.at(-1), 'destroyed'); +}); + +test('an image layer is one texture; the same canvas again uploads its pixels', () => { + const f = fixture(); + const canvas = { width: 8, height: 4, getContext: () => ({}) }; + const layer = f.imagery.createLayer({ + id: 'heat', + source: { kind: 'image', image: canvas }, + order: 1, + }); + const [slot] = f.last().slots; + assert.deepEqual(slot.rect, [-180, -90, 180, 90]); + assert.deepEqual(slot.bounds, slot.rect); + assert.deepEqual(slot.look, [1, 0, 0, 0], 'geographic'); + assert.equal(slot.texture.image, canvas); + assert.equal(slot.texture.flipY, false, 'row 0 is the north edge'); + const version = slot.texture.version; + layer.setSource({ kind: 'image', image: canvas }); + assert.equal(f.last().slots[0].texture, slot.texture); + assert.ok(slot.texture.version > version, 'its new pixels upload'); + // Across the antimeridian the east edge comes after the west one. + layer.setSource({ + kind: 'image', + image: { width: 2, height: 2, data: new Uint8Array(16) }, + rectangle: { west: 170, south: -10, east: -170, north: 10 }, + }); + assert.deepEqual(f.last().slots[0].rect, [170, -10, 190, 10]); + layer.setAlpha(0); + assert.deepEqual(f.last().slots, [], 'alpha 0 draws nothing'); + layer.setAlpha(1); + layer.show(false); + assert.deepEqual(f.last().slots, []); +}); + +test('the pass draws the shown layers bottom first, at most its slot count', () => { + const f = fixture(); + const image = { width: 1, height: 1, data: new Uint8Array(4) }; + const layers = Array.from({ length: MAX_IMAGERY_SLOTS + 2 }, (_, index) => + f.imagery.createLayer({ + id: `layer-${index}`, + source: { kind: 'image', image }, + order: -index, + alpha: 0.5 + index / 100, + }), + ); + const drawn = f.last().slots.map((slot) => slot.look[0]); + assert.equal(drawn.length, MAX_IMAGERY_SLOTS); + // Highest order last; the lowest two are left out. + assert.deepEqual( + drawn, + layers + .slice(0, MAX_IMAGERY_SLOTS) + .map((layer) => layer.alpha()) + .reverse(), + ); + f.imagery.setSplitPosition(0.3); + assert.equal(f.last().split, 0.3); + assert.equal(f.imagery.surface(), 'globe'); + f.setPhotoreal(true); + assert.equal(f.imagery.surface(), 'tiles'); +}); + +test('a hidden tiles layer drops its atlas; shown again it builds one at the view', async () => { + 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.credits.get('NASA GIBS'), 0, 'its credit goes too'); + f.changed.raiseEvent(); + assert.equal(f.diagnostics().a.atlas, null); + layer.show(true); + await tick(); + assert.ok(f.diagnostics().a.atlas); + assert.equal(f.last().slots.length, 1); +}); diff --git a/src/globe/navara/imagery/pass.js b/src/globe/navara/imagery/pass.js new file mode 100644 index 000000000..8aecac128 --- /dev/null +++ b/src/globe/navara/imagery/pass.js @@ -0,0 +1,222 @@ +// engine.imagery's screen pass on Navara: one public EffectDesc right after +// tone mapping, under every other surface pass (the remaining layer drapes, +// engine.ground) and before the sharpening, anti-aliasing and gev's +// overlays. Each pixel's world position comes from Navara's packed scene +// depth (terrain, 3D tiles); where that is the ground (not a mesh in front +// of it), up to MAX_IMAGERY_SLOTS layers are sampled in draw order at its +// longitude and latitude, each in its rectangle, on its side of the split: +// an image in longitude and latitude, a tile atlas in Web Mercator. Texels +// are blended in display space by their alpha times the layer's, as +// Cesium's globe blends imagery layers. Mip levels come from the +// derivatives of the ground position, taken before any branch. Costs +// nothing while no layer is drawn (the effect is hidden). + +import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three'; +import { Effect, EffectAttribute } from 'postprocessing'; +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 SLOTS = Array.from({ length: MAX_IMAGERY_SLOTS }, (_, index) => index); + +const fragmentShader = /* glsl */ ` +uniform sampler2D tAllDepth; +uniform sampler2D tSurfaceDepth; +uniform mat4 uInverseProjection; +uniform mat4 uWorld; +uniform float uLogDepthFC; +uniform float uSplit; +${SLOTS.map( + (i) => `uniform sampler2D tImage${i}; +uniform vec4 uRect${i}; +uniform vec4 uBounds${i}; +uniform vec4 uLook${i};`, +).join('\n')} +vec3 toDisplay(vec3 c) { + return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c)); +} +vec3 toLinear(vec3 c) { + return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c)); +} +// One layer over the colour: rect is where it draws (degrees, east past +// west), bounds the image's extent (degrees) or the atlas's (Web Mercator +// x, y, width, height), look its alpha, side (-1 left, 1 right, 0 both) +// and projection (1 Web Mercator). +vec3 paint(vec3 color, sampler2D image, vec4 rect, vec4 bounds, vec4 look, + vec2 geo, vec2 geoDx, vec2 geoDy, vec2 merc, vec2 mercDx, vec2 mercDy, float x) { + if (look.x <= 0.0) return color; + if ((look.y < 0.0 && x > uSplit) || (look.y > 0.0 && x < uSplit)) return color; + float lon = geo.x < rect.x ? geo.x + 360.0 : geo.x; + if (lon > rect.z || geo.y < rect.y || geo.y > rect.w) return color; + vec2 at; + vec2 dx; + vec2 dy; + if (look.z > 0.5) { + vec2 m = vec2(merc.x + (lon - geo.x) / 360.0, merc.y); + at = (m - bounds.xy) / bounds.zw; + dx = mercDx / bounds.zw; + dy = mercDy / bounds.zw; + } else { + vec2 span = vec2(bounds.z - bounds.x, bounds.y - bounds.w); + at = vec2(lon - bounds.x, geo.y - bounds.w) / span; + dx = geoDx / span; + dy = geoDy / span; + } + if (any(lessThan(at, vec2(0.0))) || any(greaterThan(at, vec2(1.0)))) return color; + vec4 texel = textureGrad(image, at, dx, dy); + return mix(color, texel.rgb, texel.a * look.x); +} +void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { + outputColor = inputColor; + float depth = unpackRGBAToDepth(texture2D(tAllDepth, uv)); + float distance = exp2(2.0 * depth / uLogDepthFC) - 1.0; + vec4 ray = uInverseProjection * vec4(uv * 2.0 - 1.0, -1.0, 1.0); + vec3 point = (uWorld * vec4(ray.xyz * (distance / -ray.z), 1.0)).xyz; + // Geodetic longitude and latitude (Bowring), degrees. + float p = length(point.xy); + float theta = atan(point.z * 6378137.0, p * 6356752.314245); + float st = sin(theta); + float ct = cos(theta); + float latitude = atan(point.z + 42841.311513 * st * st * st, p - 42697.672707 * ct * ct * ct); + vec2 geo = vec2(atan(point.y, point.x), latitude) * 57.29577951308232; + float clamped = clamp(latitude, -1.4844222297, 1.4844222297); + vec2 merc = vec2((geo.x + 180.0) / 360.0, + (1.0 - log(tan(0.7853981633974483 + clamped * 0.5)) / 3.141592653589793) / 2.0); + // Derivatives before any divergent branch; across the antimeridian a + // longitude step wraps; across the sky's edge there is none. + vec2 geoDx = dFdx(geo); + vec2 geoDy = dFdy(geo); + vec2 mercDx = dFdx(merc); + vec2 mercDy = dFdy(merc); + geoDx.x -= 360.0 * floor(geoDx.x / 360.0 + 0.5); + geoDy.x -= 360.0 * floor(geoDy.x / 360.0 + 0.5); + mercDx.x -= floor(mercDx.x + 0.5); + mercDy.x -= floor(mercDy.x + 0.5); + float onSurface = 1.0 - step(1.0, depth); + float edge = 1.0 - step(0.5, abs(dFdx(onSurface)) + abs(dFdy(onSurface))); + geoDx *= edge; + geoDy *= edge; + mercDx *= edge; + mercDy *= edge; + if (depth >= 1.0) return; + // The packed depth also holds forward meshes, which are not ground. + if (depth < texture2D(tSurfaceDepth, uv).r - 2e-7) return; + vec3 color = toDisplay(inputColor.rgb); + ${SLOTS.map( + (i) => + `color = paint(color, tImage${i}, uRect${i}, uBounds${i}, uLook${i}, geo, geoDx, geoDy, merc, mercDx, mercDy, uv.x);`, + ).join('\n ')} + outputColor = vec4(toLinear(color), inputColor.a); +} +`; + +class ImageryEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) { + static key = IMAGERY_EFFECT_KEY; + // Navara takes the first anchor that exists: right after tone mapping. + static insertAfter = ['toneMapping']; + static insertBefore = [ + 'gevNepalEvent', + 'gevObservedWeather', + 'gevEarthquakeGround', + 'gevAwarenessPulses', + 'gevGround', + 'gevPreSharpenOverlay', + 'gevSharpen', + 'smaa', + 'fxaa', + 'gevOverlay', + 'final', + ]; + + constructor(view, ctx, config) { + super(view, ctx, config); + this.owner = config[IMAGERY_EFFECT_KEY].owner; + } + + createPass() { + const camera = this.view.camera.raw; + if (!camera) throw new Error('Camera not available for the imagery pass'); + this.effect = new Effect('GevImagery', fragmentShader, { + // Its own pass: inputColor is the tone-mapped frame. + attributes: EffectAttribute.CONVOLUTION, + uniforms: new Map([ + ['tAllDepth', new Uniform(null)], + ['tSurfaceDepth', new Uniform(null)], + ['uInverseProjection', new Uniform(new Matrix4())], + ['uWorld', new Uniform(new Matrix4())], + ['uLogDepthFC', new Uniform(1)], + ['uSplit', new Uniform(0.5)], + ...SLOTS.flatMap((i) => [ + [`tImage${i}`, new Uniform(null)], + [`uRect${i}`, new Uniform([0, 0, 0, 0])], + [`uBounds${i}`, new Uniform([0, 0, 1, 1])], + [`uLook${i}`, new Uniform([0, 0, 0, 0])], + ]), + ]), + }); + return new NavaraEffect(camera, this.effect); + } + + updateFrame() { + const camera = this.view.camera.raw; + const owner = this.owner; + if (!this.effect || !camera) return; + const mrt = this.find('mrt'); + if (mrt?.depthBufferPacking !== RGBADepthPacking) + throw new Error('The imagery pass needs packed scene depth'); + const u = this.effect.uniforms; + u.get('tAllDepth').value = mrt.depthBuffer; + u.get('tSurfaceDepth').value = this.ctx.getRenderTarget().depthTexture; + u.get('uInverseProjection').value.copy(camera.projectionMatrixInverse); + u.get('uWorld').value.copy(camera.matrixWorld); + u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1); + u.get('uSplit').value = owner.split; + for (const i of SLOTS) { + const slot = owner.slots[i]; + u.get(`tImage${i}`).value = slot?.texture ?? owner.empty; + u.get(`uRect${i}`).value = slot?.rect ?? [0, 0, 0, 0]; + u.get(`uBounds${i}`).value = slot?.bounds ?? [0, 0, 1, 1]; + u.get(`uLook${i}`).value = slot?.look ?? [0, 0, 0, 0]; + } + } +} + +const registered = new WeakSet(); + +/** + * The pass of one view: set(slots, split) draws up to MAX_IMAGERY_SLOTS + * layers ({ texture, rect, bounds, look }, bottom first; none: the effect + * hidden) with the split at `split` (a fraction of the width from the left). + */ +export function createImageryPass(view) { + if (!registered.has(view)) { + view.registerEffect(IMAGERY_EFFECT_KEY, ImageryEffectDesc); + registered.add(view); + } + const empty = new DataTexture(new Uint8Array(4), 1, 1); + empty.needsUpdate = true; + const owner = { slots: [], split: 0.5, empty }; + let handle = null; + return { + owner, + set(slots, split) { + owner.slots = slots.slice(0, MAX_IMAGERY_SLOTS); + owner.split = split; + const visible = owner.slots.length > 0; + if (visible && !handle) + handle = view.addEffect({ + [IMAGERY_EFFECT_KEY]: { owner }, + visible: true, + }); + else handle?.update({ visible }); + }, + destroy() { + handle?.delete(); + handle = null; + owner.slots = []; + empty.dispose(); + }, + }; +} diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 1d83a781b..e9d6ffeaa 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -36,6 +36,8 @@ import { createNavaraMovers } from './movers/index.js'; import { createNavaraLines } from './lines/index.js'; import { createNavaraGround } from './ground/index.js'; import { createGroundPass } from './ground/pass.js'; +import { createNavaraImagery } from './imagery/index.js'; +import { createImageryPass } from './imagery/pass.js'; import { createNavaraOverlay } from './overlay.js'; import { createPostSampling } from './postSampling.js'; import { @@ -173,6 +175,8 @@ export async function createNavaraGlobe({ container, search, hd = false }) { }), lines: createNavaraLines(view, { overlay }), ground: createNavaraGround(view, { createPass: createGroundPass }), + imagery: (parts) => + createNavaraImagery(view, { ...parts, createPass: createImageryPass }), }); const camera = engine.camera; let ground = Number.NaN; diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index bef51d157..d02b44f7b 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -2747,6 +2747,7 @@ export function createContractSuite( ]; checks.push( ...groundChecks(engine, { settle, until, nadirView, readRegion, pointer }), + ...imageryChecks(engine, { settle, until, nadirView, readRegion }), ); if (mapSources?.photoreal && mapSources.imagery?.length >= 2) checks.push(...mapChecks(engine.maps, mapSources)); @@ -3241,6 +3242,350 @@ function groundChecks( ]; } +// engine.imagery: raster layers on the ground (every engine). +function imageryChecks(engine, { settle, until, nadirView, readRegion }) { + const { imagery } = engine; + const throwsType = (run, label) => { + let error = null; + try { + run(); + } catch (caught) { + error = caught; + } + ensure(error instanceof TypeError, `${label} did not throw a TypeError`); + }; + // A flat colour as RGBA bytes (no canvas needed), size × size texels. + const flat = (rgba, size = 8) => { + const data = new Uint8Array(size * size * 4); + for (let i = 0; i < data.length; i += 4) data.set(rgba, i); + return { width: size, height: size, data }; + }; + const box = { west: 19, south: 9.5, east: 20, north: 10.5 }; + const listed = (id) => imagery.list().find((entry) => entry.id === id); + return [ + [ + 'imagery: layers are created, listed, restyled, ordered and destroyed', + async () => { + const source = { kind: 'image', image: flat([255, 0, 255, 255]) }; + throwsType( + () => + imagery.createLayer({ id: 'contract-bad', source: { kind: 'x' } }), + 'an unknown source kind', + ); + throwsType( + () => + imagery.createLayer({ + id: 'contract-bad', + source: { + kind: 'tiles', + url: 'https://example.test/{z}/{x}.png', + }, + }), + 'a template without {y}', + ); + throwsType( + () => + imagery.createLayer({ + id: 'contract-bad', + source: { + kind: 'image', + image: { width: 2, height: 2, data: new Uint8Array(3) }, + }, + }), + 'RGBA bytes of the wrong length', + ); + throwsType( + () => imagery.createLayer({ id: 'contract-bad', source, alpha: 2 }), + 'an alpha above 1', + ); + throwsType( + () => + imagery.createLayer({ id: 'contract-bad', source, split: 'up' }), + 'an unknown split', + ); + throwsType( + () => + imagery.createLayer({ + id: 'contract-bad', + source: { + ...source, + rectangle: { west: 0, south: 5, east: 1, north: 4 }, + }, + }), + 'a rectangle upside down', + ); + ensure(!listed('contract-bad'), 'a refused layer is listed'); + ensure( + ['globe', 'tiles', 'none'].includes(imagery.surface()), + `surface() ${imagery.surface()}`, + ); + const top = imagery.createLayer({ + id: 'contract-imagery-top', + source, + order: 2, + }); + const bottom = imagery.createLayer({ + id: 'contract-imagery-bottom', + source: { + kind: 'image', + image: flat([0, 255, 0, 255]), + rectangle: box, + }, + alpha: 0.5, + show: false, + split: 'left', + }); + const split = imagery.splitPosition(); + try { + let duplicate = null; + try { + imagery.createLayer({ id: 'contract-imagery-top', source }); + } catch (error) { + duplicate = error; + } + ensure(duplicate, 'a second live layer with the same id'); + ensure(top.id === 'contract-imagery-top', `id ${top.id}`); + const ids = imagery.list().map(({ id }) => id); + ensure( + ids.indexOf('contract-imagery-bottom') < + ids.indexOf('contract-imagery-top'), + `list() is not in draw order: ${ids.join(', ')}`, + ); + ensure( + listed('contract-imagery-bottom')?.kind === 'image' && + listed('contract-imagery-bottom').shown === false && + listed('contract-imagery-bottom').alpha === 0.5 && + listed('contract-imagery-bottom').split === 'left' && + listed('contract-imagery-top').order === 2, + `list(): ${JSON.stringify(imagery.list())}`, + ); + ensure( + bottom.shown() === false && + bottom.alpha() === 0.5 && + bottom.split() === 'left', + 'the options are not the layer state', + ); + bottom.show(true); + bottom.setAlpha(0.25); + bottom.setSplit('right'); + ensure( + bottom.shown() === true && + bottom.alpha() === 0.25 && + bottom.split() === 'right' && + listed('contract-imagery-bottom').alpha === 0.25, + 'a restyle is not reported', + ); + throwsType(() => bottom.setAlpha(-1), 'a negative alpha'); + throwsType(() => bottom.setSplit('down'), 'an unknown side'); + throwsType( + () => bottom.setSource({ kind: 'image' }), + 'an empty source', + ); + ensure( + bottom.source().kind === 'image', + 'a refused source replaced it', + ); + bottom.setSource({ + kind: 'tiles', + url: 'https://tiles.invalid/{z}/{x}/{y}.png', + maxLevel: 3, + rectangle: box, + }); + ensure( + bottom.source().kind === 'tiles' && + listed('contract-imagery-bottom').kind === 'tiles', + 'setSource() did not replace the source', + ); + imagery.setSplitPosition(0.25); + ensure(imagery.splitPosition() === 0.25, 'the split position'); + throwsType( + () => imagery.setSplitPosition(1.5), + 'a split outside 0..1', + ); + await until(() => top.ready(), 'the image layer ready'); + await settle(); + } finally { + imagery.setSplitPosition(split); + top.destroy(); + bottom.destroy(); + } + ensure(!listed('contract-imagery-top'), 'a destroyed layer is listed'); + ensure(!top.ready(), 'a destroyed layer is ready'); + top.destroy(); + let refused = null; + try { + top.setAlpha(1); + } catch (error) { + refused = error; + } + ensure(refused, 'a destroyed layer took a restyle'); + }, + ], + [ + 'imagery: images draw on the ground by alpha, show, order and split (pixels)', + async () => { + if (!readRegion) return; + await nadirView(1e6); + const MAGENTA = [255, 0, 255]; + const GREEN = [0, 255, 0]; + const screenOf = (lon, lat) => + engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat)); + // The mean colour in a box centred on a ground point. + const meanAt = (lon, lat, half = 6) => { + const { x, y } = screenOf(lon, lat); + const data = readRegion( + Math.round(x) - half, + Math.round(y) - half, + 2 * half, + 2 * half, + ); + const mean = [0, 0, 0]; + for (let i = 0; i < data.length; i += 4) + for (let k = 0; k < 3; k++) mean[k] += data[i + k]; + return mean.map((sum) => sum / (data.length / 4)); + }; + const near = (actual, expected, tolerance = 40) => + actual.every( + (value, k) => Math.abs(value - expected[k]) <= tolerance, + ); + const show = (colour) => + colour.map((value) => value.toFixed(0)).join(','); + // The map's own tiles loaded: what lies under the layers holds still. + await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded'); + await settle(4); + const base = meanAt(19.5, 10); + ensure(!near(base, MAGENTA), 'the globe is already magenta there'); + const magenta = imagery.createLayer({ + id: 'contract-paint-magenta', + source: { + kind: 'image', + image: flat([...MAGENTA, 255]), + rectangle: box, + }, + }); + const layers = [magenta]; + try { + await until(() => magenta.ready(), 'the magenta layer ready'); + await until( + () => near(meanAt(19.5, 10), MAGENTA), + 'the magenta layer drawn', + ); + ensure( + near(meanAt(21, 10), base, 60) || !near(meanAt(21, 10), MAGENTA), + 'the layer spills outside its rectangle', + ); + // Alpha: at 0 the globe under it, at 0.5 half way to magenta. + magenta.setAlpha(0); + await until(() => !near(meanAt(19.5, 10), MAGENTA), 'alpha 0 drawn'); + await settle(2); + const below = meanAt(19.5, 10); + magenta.setAlpha(0.5); + const half = below.map((value, k) => (value + MAGENTA[k]) / 2); + await until( + () => near(meanAt(19.5, 10), half, 30), + `alpha 0.5 drawn (${show(half)} wanted)`, + ); + magenta.setAlpha(1); + magenta.show(false); + await until( + () => near(meanAt(19.5, 10), below, 30), + 'the hidden layer gone', + ); + magenta.show(true); + await until( + () => near(meanAt(19.5, 10), MAGENTA), + 'the layer shown again', + ); + // Order: a higher order draws on top, a lower one under it. + const green = imagery.createLayer({ + id: 'contract-paint-green', + source: { + kind: 'image', + image: flat([...GREEN, 255]), + rectangle: box, + }, + order: 1, + }); + layers.push(green); + const lower = imagery.createLayer({ + id: 'contract-paint-under', + source: { + kind: 'image', + image: flat([0, 255, 255, 255]), + rectangle: box, + }, + order: -1, + }); + layers.push(lower); + await until( + () => near(meanAt(19.5, 10), GREEN), + 'the higher order on top', + ); + green.destroy(); + await until( + () => near(meanAt(19.5, 10), MAGENTA), + 'the destroyed layer gone', + ); + // A new source in place: the same layer, another colour. + magenta.setSource({ + kind: 'image', + image: flat([...GREEN, 255]), + rectangle: box, + }); + await until( + () => near(meanAt(19.5, 10), GREEN), + 'the new source drawn', + ); + magenta.setSource({ + kind: 'image', + image: flat([...MAGENTA, 255]), + rectangle: box, + }); + lower.destroy(); + // Split: a left layer draws only left of the split position. + const width = engine.host.size().width; + const middle = screenOf(19.5, 10).x; + imagery.setSplitPosition(middle / width); + magenta.setSplit('left'); + const left = screenOf(19.25, 10); + const right = screenOf(19.75, 10); + const at = ({ x, y }) => { + const data = readRegion(Math.round(x) - 3, Math.round(y) - 3, 6, 6); + const mean = [0, 0, 0]; + for (let i = 0; i < data.length; i += 4) + for (let k = 0; k < 3; k++) mean[k] += data[i + k] / 36; + return mean; + }; + await until( + () => near(at(left), MAGENTA) && !near(at(right), MAGENTA), + `the left split drawn (left ${show(at(left))}, right ${show(at(right))})`, + ); + magenta.setSplit('right'); + await until( + () => !near(at(left), MAGENTA) && near(at(right), MAGENTA), + 'the right split drawn', + ); + magenta.setSplit('none'); + await until( + () => near(at(left), MAGENTA) && near(at(right), MAGENTA), + 'the unsplit layer drawn', + ); + } finally { + imagery.setSplitPosition(0.5); + for (const layer of layers) layer.destroy(); + } + await until( + () => + [MAGENTA, GREEN, [0, 255, 255]].every( + (colour) => !near(meanAt(19.5, 10), colour), + ), + 'every layer gone', + ); + }, + ], + ]; +} + // The optional contactSonar capability (render.setContactSonar). function contactSonarChecks(render, settle) { return [ diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 65fc18bbb..021fadd37 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -50,6 +50,7 @@ import { viewRectangle, } from '../cameraMath.js'; import { createGroundPicks, createGroundRegistry } from '../ground.js'; +import { createImageryRegistry } from '../imagery.js'; import { createLineRegistry } from '../lines.js'; import { createMoverRegistry } from '../movers.js'; import { createCpuPicker } from '../cpuPicks.js'; @@ -268,6 +269,27 @@ export function createFakeEngine(options = {}) { }, destroy() {}, })); + // Imagery layers keep their state for tests; every change is a render + // request. They drape on the globe, or on tiles while photoreal shows. + const imageryRegistry = createImageryRegistry( + () => ({ + source() { + fake.renderRequests++; + }, + look() { + fake.renderRequests++; + }, + destroy() { + fake.renderRequests++; + }, + }), + { + splitPosition() { + fake.renderRequests++; + }, + surface: () => (fake.maps.photoreal != null ? 'tiles' : 'globe'), + }, + ); const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()])); const livePosition = cartesian3.create(); let lastBaseImagery = null; @@ -664,6 +686,7 @@ export function createFakeEngine(options = {}) { movers: moverRegistry.movers, lines: lineRegistry.lines, ground: groundRegistry.ground, + imagery: imageryRegistry.imagery, clock: { now: () => appClock.now(), frameTime: () => appClock.frameTime(), @@ -802,6 +825,7 @@ export function createFakeEngine(options = {}) { moverRegistry.destroyAll(); lineRegistry.destroyAll(); groundRegistry.destroyAll(); + imageryRegistry.destroyAll(); fake.destroyed = true; }, isDestroyed: () => fake.destroyed, @@ -889,6 +913,24 @@ export function createFakeEngine(options = {}) { } : undefined; }, + /** + * A live imagery layer's state, by id: { options, source, alpha, shown, + * split }; `layers()` all of them in draw order. + */ + imageryLayer(id) { + const state = imageryRegistry.live.get(id); + return state + ? { + options: state.options, + source: state.source, + alpha: state.alpha, + shown: state.shown, + split: state.split, + } + : undefined; + }, + imageryLayers: () => + imageryRegistry.ordered().map(({ options }) => options.id), /** The app clock the engine clock reads (freeze/offset/rate for tests). */ appClock, /** Moves the fake wall clock without a frame (own app clock only). */ diff --git a/src/layers/awareness/render/navaraPulses.js b/src/layers/awareness/render/navaraPulses.js index f37f24d1e..d6c8af874 100644 --- a/src/layers/awareness/render/navaraPulses.js +++ b/src/layers/awareness/render/navaraPulses.js @@ -88,12 +88,10 @@ const pulseEffect = defineGroundEffect({ name: 'GevAwarenessPulses', fragmentShader, insertAfter: [ - 'gevEarthquakeGround', + 'gevGround', 'gevObservedWeather', 'gevNepalEvent', - 'gevRecentImagery', - 'gevFirmsHeat', - 'gevFirmsGround', + 'gevImagery', 'toneMapping', ], insertBefore: [ diff --git a/src/layers/firms/edgeCases.test.mjs b/src/layers/firms/edgeCases.test.mjs index be8449d15..1c152fb6c 100644 --- a/src/layers/firms/edgeCases.test.mjs +++ b/src/layers/firms/edgeCases.test.mjs @@ -4,7 +4,6 @@ import * as geo from '../../geo/index.js'; import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { createFirmsHeatmapLayer } from './index.js'; import { createFirmsDisplay } from './render/display.js'; -import { createFirmsHeatDrape } from './render/navaraHeat.js'; const settle = async () => { for (let i = 0; i < 5; i++) @@ -64,32 +63,6 @@ function fixture() { roll: 0, }); pose(12_000_000); - const effects = []; - const native = { - view: { - camera: {}, - registerEffect() {}, - addEffect(config) { - const handle = { - config, - visible: config.visible, - deleted: false, - update(next) { - if ('visible' in next) handle.visible = next.visible; - }, - delete() { - handle.deleted = true; - }, - }; - effects.push(handle); - return handle; - }, - }, - EffectDesc: class {}, - NavaraEffect: class {}, - }; - // The heatmap draws through the Navara backend on this view. - engine.extensions = { navara: native }; const noop = () => {}; const layer = createFirmsHeatmapLayer({ id: 'local-firms', @@ -117,8 +90,7 @@ function fixture() { }, overlayHost: { setEntries: noop, setVisible: noop, clearSource: noop }, renderer: { - create: ({ engine: target, id }) => - createFirmsDisplay(target, { id, navaraHeat: createFirmsHeatDrape }), + create: ({ engine: target, id }) => createFirmsDisplay(target, { id }), }, }); layer.init(null, { engine }); @@ -129,18 +101,15 @@ function fixture() { engine, layer, pose, - // The heatmap as drawn: its raster's alpha at the fire, 0 when the - // drape is hidden, faded out or unpainted. + // The heatmap as drawn: its raster's alpha at the fire times the + // layer's, 0 when the imagery layer is hidden, faded out or unpainted. drawnHeat() { - const handle = effects.find( - (effect) => effect.config.gevFirmsHeat && !effect.deleted, - ); - const owner = handle?.config.gevFirmsHeat.owner; - if (!handle?.visible || !owner?.texture || !(owner.opacity > 0)) return 0; - const { data, width, height } = owner.texture.image; + const heat = engine.fake.imageryLayer('local-firms:heat'); + if (!heat?.shown || !(heat.alpha > 0)) return 0; + const { data, width, height } = heat.source.image; const x = Math.floor(((FIRE.lon + 180) / 360) * width); const y = Math.floor(((90 - FIRE.lat) / 180) * height); - return (data[(y * width + x) * 4 + 3] / 255) * owner.opacity; + return (data[(y * width + x) * 4 + 3] / 255) * heat.alpha; }, cards: () => layer.getStats().cells, // Fire sprites actually drawn (shown instances of the engine set). diff --git a/src/layers/firms/heatImagery.js b/src/layers/firms/heatImagery.js deleted file mode 100644 index 710a87f43..000000000 --- a/src/layers/firms/heatImagery.js +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Drapes the Active Fires heat raster over the globe as one full-extent - * imagery tile on the active imagery host (globe or 3D-tiles), like the wind - * layer's scalar field. Owns the canvas and the imagery layer; nothing else - * in the fires layer touches imagery. - * - * `show` is only for the layer being on or off. The zoom hand-off fades - * `alpha` instead: Cesium unmaps a hidden imagery layer from the globe - * tiles and re-maps it when shown, so a show toggle at the band edge left - * a beat with neither markers nor heat. At alpha 0 the layer stays mapped - * and costs no draw. - */ - -const defaultCanvas = (width, height) => { - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - return canvas; -}; - -export function createHeatImagery({ - cesium, - getHost, - createCanvas = defaultCanvas, -}) { - let raster = null; - let layer = null; - let collection = null; - let show = true; - let opacity = 1; - - function detach() { - if (layer && collection) collection.remove(layer, true); - layer = null; - collection = null; - } - - function attach() { - detach(); - if (!raster) return; - const host = getHost?.() ?? { collection: null, kind: 'none' }; - if (!host.collection) return; - const canvas = createCanvas(raster.width, raster.height); - const ctx = canvas.getContext('2d'); - const image = ctx.createImageData(raster.width, raster.height); - image.data.set(raster.rgba); - ctx.putImageData(image, 0, 0); - const provider = new cesium.SingleTileImageryProvider({ - url: canvas.toDataURL('image/png'), - tileWidth: raster.width, - tileHeight: raster.height, - rectangle: cesium.Rectangle.MAX_VALUE, - }); - collection = host.collection; - layer = collection.addImageryProvider(provider); - layer.show = show; - layer.alpha = opacity; - collection.raiseToTop?.(layer); - } - - return { - setRaster(next) { - raster = next; - attach(); - }, - /** True once a raster was handed over (painted or waiting for a host). */ - hasRaster() { - return raster !== null; - }, - setShow(value) { - show = Boolean(value); - if (layer) layer.show = show; - }, - /** Hand-off fade, 0..1; the layer stays mapped at 0. */ - setOpacity(value) { - opacity = Math.min(1, Math.max(0, Number(value) || 0)); - if (layer) layer.alpha = opacity; - }, - rehome() { - const host = getHost?.(); - if ((host?.collection ?? null) !== collection) attach(); - }, - destroy() { - detach(); - raster = null; - }, - }; -} diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs deleted file mode 100644 index 27f45f55f..000000000 --- a/src/layers/firms/heatImagery.test.mjs +++ /dev/null @@ -1,128 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createHeatImagery } from './heatImagery.js'; -import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; -import { HEAT_RASTER } from './policy.js'; - -function harness() { - const layers = []; - const removed = []; - const collection = { - addImageryProvider(provider) { - const layer = { provider, show: true, alpha: 1 }; - layers.push(layer); - return layer; - }, - remove(layer, destroy) { - layers.splice(layers.indexOf(layer), 1); - removed.push({ layer, destroy }); - }, - raiseToTop() {}, - }; - let host = { collection, kind: 'globe' }; - const cesium = { - Rectangle: { MAX_VALUE: { max: true } }, - SingleTileImageryProvider: class { - constructor(options) { - this.options = options; - } - }, - }; - const createCanvas = (w, h) => ({ - width: w, - height: h, - getContext: () => ({ - createImageData: (cw, ch) => ({ - data: new Uint8ClampedArray(cw * ch * 4), - }), - putImageData() {}, - }), - toDataURL: () => 'data:image/png;base64,AAAA', - }); - const imagery = createHeatImagery({ - cesium, - getHost: () => host, - createCanvas, - }); - return { imagery, layers, removed, collection, setHost: (h) => (host = h) }; -} - -const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) }; - -test('setRaster drapes one full-globe tile and replaces it on the next build', () => { - const h = harness(); - h.imagery.setRaster(raster); - assert.equal(h.layers.length, 1); - assert.deepEqual(h.layers[0].provider.options.rectangle, { max: true }); - assert.equal(h.layers[0].provider.options.tileWidth, 4); - h.imagery.setRaster(raster); - assert.equal(h.layers.length, 1, 'never two heat layers'); - assert.equal(h.removed[0].destroy, true); -}); - -test('setShow toggles without rebuilding', () => { - const h = harness(); - h.imagery.setRaster(raster); - const layer = h.layers[0]; - h.imagery.setShow(false); - assert.equal(layer.show, false); - h.imagery.setShow(true); - assert.equal(h.layers[0], layer); - assert.equal(layer.show, true); -}); - -test('setOpacity fades the same layer and survives a rebuild', () => { - const h = harness(); - h.imagery.setRaster(raster); - const layer = h.layers[0]; - h.imagery.setOpacity(0); - assert.equal(layer.alpha, 0); - assert.equal(layer.show, true, 'a faded layer stays mapped'); - h.imagery.setOpacity(0.25); - assert.equal(h.layers[0], layer); - assert.equal(layer.alpha, 0.25); - h.imagery.setRaster(raster); - assert.equal(h.layers[0].alpha, 0.25, 'a rebuilt layer keeps the fade'); - h.imagery.setOpacity(7); - assert.equal(h.layers[0].alpha, 1); -}); - -test('no imagery host keeps nothing draped; rehome brings it back', () => { - const h = harness(); - h.setHost({ collection: null, kind: 'none' }); - h.imagery.setRaster(raster); - assert.equal(h.layers.length, 0); - h.setHost({ collection: h.collection, kind: 'globe' }); - h.imagery.rehome(); - assert.equal(h.layers.length, 1); -}); - -test('destroy removes the layer', () => { - const h = harness(); - h.imagery.setRaster(raster); - h.imagery.destroy(); - assert.equal(h.layers.length, 0); -}); - -test('the shipped raster paints a lone weak fire as a distinct spot', () => { - // A hot cluster elsewhere sets the scale, as Africa does on real data. - const fires = [{ lat: 0, lon: 0, frp: 5 }]; - for (let i = 0; i < 40; i++) { - fires.push({ lat: -10 + (i % 5) * 0.1, lon: 25 + i * 0.05, frp: 300 }); - } - const r = buildHeatRaster(fires, HEAT_RASTER); - const y = Math.floor((90 / 180) * r.height); - const painted = []; - for (let x = 0; x < r.width; x++) { - const lon = ((x + 0.5) / r.width) * 360 - 180; - if (Math.abs(lon) < 5 && r.rgba[(y * r.width + x) * 4 + 3] > 0) { - painted.push(x); - } - } - const kmPerPx = 40075 / r.width; - assert.ok(painted.length > 0, 'the weak fire still shows'); - assert.ok( - painted.length * kmPerPx <= 100, - `spot ${Math.round(painted.length * kmPerPx)} km wide`, - ); -}); diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index ebd9904cb..0be598a6e 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -23,21 +23,6 @@ export function createLifecycle({ } const methods = { - /** - * Shell services: `imageryHost()` resolves where draped imagery goes on - * the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The - * heatmap moves with it; without a host it drapes on the globe's own - * imagery (render/heatField.js). - * @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services - */ - attachShellServices(services) { - layerState._imageryHost = - typeof services?.imageryHost === 'function' - ? services.imageryHost - : null; - layerState._heatImagery?.rehome(); - }, - init(viewer, options) { this.attachRenderer(viewer, options); }, @@ -161,7 +146,6 @@ export function createLifecycle({ layerState._cellCacheByGrid.clear(); layerState.glowSpriteCache.clear(); releaseHeat(); - layerState._imageryHost = null; layerState._count = 0; layerState._totalCount = 0; layerState._rowControlsListener = null; diff --git a/src/layers/firms/render/cesiumHeat.js b/src/layers/firms/render/cesiumHeat.js deleted file mode 100644 index 559d6d756..000000000 --- a/src/layers/firms/render/cesiumHeat.js +++ /dev/null @@ -1,20 +0,0 @@ -// The heatmap's Cesium backend (render/heatField.js): one full-globe imagery -// tile on the active map stack's imagery host, else on the viewer's imagery -// layers (heatImagery.js). -import * as Cesium from 'cesium'; -import { createHeatImagery } from '../heatImagery.js'; - -/** - * @param {{ viewer: object }} cesium The engine's Cesium extension. - * @param {() => ({ collection: object, kind: string } | null)} [imageryHost] - */ -export function createFirmsCesiumHeat({ viewer }, imageryHost) { - return createHeatImagery({ - cesium: Cesium, - getHost: () => - imageryHost?.() ?? { - collection: viewer.imageryLayers ?? null, - kind: 'globe', - }, - }); -} diff --git a/src/layers/firms/render/display.js b/src/layers/firms/render/display.js index 742d902e5..3a9ccf595 100644 --- a/src/layers/firms/render/display.js +++ b/src/layers/firms/render/display.js @@ -5,15 +5,17 @@ // without an outline, not pickable; // - clicks: the engine's selection dispatcher, with the pick under the click; // - the camera, projection, view rectangle and horizon the layer reads: the -// engine's (engine.camera, engine.projector). -// The far-zoom heatmap is a draped raster, which the contract has no -// primitive for; it sits behind render/heatField.js. +// engine's (engine.camera, engine.projector); +// - the far-zoom heatmap: one engine.imagery layer, the heat raster as RGBA +// bytes over the whole globe, faded by its alpha for the hand-off. import { createEngineLayerView } from '../../shared/render/engineView.js'; import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js'; import { LOD_LEVELS } from '../policy.js'; import { createFirmsSpriteBank } from './sprites.js'; -import { createFirmsHeatField } from './heatField.js'; + +// Above imagery layers of the default order (a pinned Recent Imagery day). +const HEAT_ORDER = 1; const rgbaOf = (color) => [ color?.red ?? 1, @@ -22,25 +24,89 @@ const rgbaOf = (color) => [ color?.alpha ?? 1, ]; +/** + * The heatmap on engine.imagery: setRaster (data/firmsHeatRaster.js's + * equirectangular RGBA over the globe; a new one replaces the image in + * place), hasRaster, setShow (the layer on or off), setOpacity (the + * hand-off fade, 0..1: a faded layer stays on its surface, so it comes + * back without a gap) and destroy. The engine layer is made with the first + * raster. + */ +function createHeatImagery(engine, id, onDestroy) { + let layer = null, + raster = null, + show = true, + opacity = 1, + destroyed = false; + const heat = { + setRaster(next) { + if (destroyed) return; + raster = next ?? null; + if (!raster) { + layer?.destroy(); + layer = null; + return; + } + const source = { + kind: 'image', + image: { + width: raster.width, + height: raster.height, + data: raster.rgba, + }, + }; + if (layer) layer.setSource(source); + else + layer = engine.imagery.createLayer({ + id, + source, + alpha: opacity, + show, + order: HEAT_ORDER, + }); + }, + /** True once a raster was handed over. */ + hasRaster: () => raster !== null, + setShow(value) { + if (destroyed) return; + show = Boolean(value); + layer?.show(show); + }, + setOpacity(value) { + if (destroyed) return; + opacity = Math.min(1, Math.max(0, Number(value) || 0)); + layer?.setAlpha(opacity); + }, + destroy() { + if (destroyed) return; + destroyed = true; + layer?.destroy(); + layer = null; + raster = null; + onDestroy(heat); + }, + }; + return heat; +} + /** * The renderer the fire layer draws through (lifecycle.js, rendering.js). * @param {import('../../../globe/contract.js').GlobeEngine} engine - * @param {{ id?: string, navaraHeat?: Function, loadImage?: Function, - * createCanvas?: Function }} [options] `navaraHeat` is the heatmap's Navara - * backend (render/navaraHeat.js), which the Navara mode passes in; + * @param {{ id?: string, loadImage?: Function, createCanvas?: Function }} [options] * `loadImage` and `createCanvas` are the sprite atlas's test seams. */ export function createFirmsDisplay( engine, - { id = 'local-firms', navaraHeat, loadImage, createCanvas } = {}, + { id = 'local-firms', loadImage, createCanvas } = {}, ) { - if (!engine?.movers || !engine?.ground) + if (!engine?.movers || !engine?.ground || !engine?.imagery) throw new TypeError('The FIRMS display needs a globe engine'); let destroyed = false, visible = false, cellItems = [], cellsQueued = false, - sprites = null; + sprites = null, + heat = null; // The layer's visibility as the context store reads a data source's. const dataSource = { show: false }; const clickRemovers = new Set(); @@ -144,14 +210,16 @@ export function createFirmsDisplay( /** The mover set drawing the glows, for tests and QA (null before any). */ spriteSet: () => sprites?.set ?? null, /** - * The far-zoom heatmap draped on this globe (render/heatField.js): on - * Cesium on the active map stack's imagery host (`imageryHost()`), else - * on the viewer's imagery layers. + * The far-zoom heatmap on this globe's imagery (createHeatImagery + * above); one at a time, destroyed with the display. */ - createHeatImagery(imageryHost) { - return destroyed - ? null - : createFirmsHeatField(engine, { imageryHost, navaraHeat }); + createHeatImagery() { + if (destroyed) return null; + heat?.destroy(); + heat = createHeatImagery(engine, `${id}:heat`, (gone) => { + if (heat === gone) heat = null; + }); + return heat; }, /** The hand-off fade: every marker at `alpha`, shown while `show`. */ fadeSprites(alpha, show) { @@ -165,6 +233,7 @@ export function createFirmsDisplay( cellItems = []; for (const release of [...clickRemovers]) release(); removeSprites(); + heat?.destroy(); cells.destroy(); layerView.destroy(); engine.render.request('firms-destroy'); diff --git a/src/layers/firms/render/display.test.mjs b/src/layers/firms/render/display.test.mjs index 1aaa3cc23..3396c3e73 100644 --- a/src/layers/firms/render/display.test.mjs +++ b/src/layers/firms/render/display.test.mjs @@ -509,110 +509,99 @@ test('the fake engine: clearing rows during a decode commits only the replacemen } }); -test('the heatmap: the Cesium engine drapes one imagery layer on the viewer, the host when there is one', () => { - const priorDocument = globalThis.document; +// A 2D canvas for the Cesium engine's image upload (RGBA bytes go on one). +function stubDocument() { + const prior = globalThis.document; globalThis.document = { createElement: () => ({ + width: 0, + height: 0, getContext: () => ({ createImageData: (w, hh) => ({ data: new Uint8ClampedArray(w * hh * 4), }), putImageData() {}, }), - toDataURL: () => 'data:image/png;base64,AAAA', }), }; - const h = cesiumHarness(); - try { - const layers = (added) => ({ - addImageryProvider(provider) { - const layer = { provider, show: true, alpha: 1 }; - added.push(layer); - return layer; - }, - remove: (layer) => added.splice(added.indexOf(layer), 1), - raiseToTop() {}, - }); - const onViewer = []; - h.viewer.imageryLayers = layers(onViewer); - const onHost = []; - let host = null; - const r = display(h); - const heat = r.createHeatImagery(() => host); - const raster = { width: 2, height: 1, rgba: new Uint8ClampedArray(8) }; - heat.setRaster(raster); - assert.equal(heat.hasRaster(), true); - assert.equal(onViewer.length, 1, 'no host: the viewer’s imagery layers'); - assert.ok(onViewer[0].provider instanceof Cesium.SingleTileImageryProvider); - heat.setOpacity(0.5); - heat.setShow(false); - assert.deepEqual([onViewer[0].alpha, onViewer[0].show], [0.5, false]); - host = { kind: 'tiles', collection: layers(onHost) }; - heat.rehome(); - assert.deepEqual( - [onViewer.length, onHost.length], - [0, 1], - 'it follows the host', - ); - assert.deepEqual([onHost[0].alpha, onHost[0].show], [0.5, false]); - heat.destroy(); - assert.equal(onHost.length, 0); - r.destroy(); - } finally { - h.destroy(); - if (priorDocument === undefined) delete globalThis.document; - else globalThis.document = priorDocument; - } -}); + return () => { + if (prior === undefined) delete globalThis.document; + else globalThis.document = prior; + }; +} -test('the heatmap: Navara draws through the backend the mode passes in; an engine without one keeps the state', () => { - const h = fakeHarness(); - try { - const first = display(h); - const plain = first.createHeatImagery(); - plain.setRaster({ width: 1, height: 1, rgba: new Uint8ClampedArray(4) }); - plain.setOpacity(2); - plain.setShow(false); - plain.rehome(); - assert.equal(plain.hasRaster(), true); - assert.deepEqual( - { ...plain.state(), raster: Boolean(plain.state().raster) }, - { raster: true, show: false, opacity: 1, drawn: false }, - ); - plain.destroy(); - first.destroy(); - - const native = { view: {} }; - h.engine.extensions = { navara: native }; - const calls = []; - const navaraHeat = (engine, navara) => { - calls.push(['create', engine === h.engine, navara === native]); - return { - setRaster: (raster) => calls.push(['raster', raster.width]), - setShow: (show) => calls.push(['show', show]), - setOpacity: (opacity) => calls.push(['opacity', opacity]), - rehome: () => calls.push(['rehome']), - destroy: () => calls.push(['destroy']), - }; - }; - const second = display(h, { navaraHeat }); - const heat = second.createHeatImagery(); - heat.setRaster({ width: 3, height: 1, rgba: new Uint8ClampedArray(12) }); - heat.setOpacity(0.25); - heat.setShow(true); - heat.rehome(); - heat.destroy(); - heat.setShow(false); - assert.deepEqual(calls, [ - ['create', true, true], - ['raster', 3], - ['opacity', 0.25], - ['show', true], - ['rehome'], - ['destroy'], - ]); - second.destroy(); - } finally { - h.destroy(); - } -}); +for (const make of HARNESSES) { + const name = make().name; + test(`${name}: the heatmap is one imagery layer over the globe, faded by its alpha`, () => { + const restore = stubDocument(); + const h = make(); + // Cesium drapes it on the globe's own imagery stack. + const stack = new Cesium.ImageryLayerCollection(); + if (h.viewer) { + h.viewer.imageryLayers = stack; + h.viewer.scene.globe.show = true; + } + try { + const r = display(h); + const heat = r.createHeatImagery(); + const listed = () => h.engine.imagery.list(); + assert.equal(heat.hasRaster(), false); + assert.deepEqual(listed(), [], 'no layer before the first raster'); + heat.setOpacity(0); + const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) }; + heat.setRaster(raster); + assert.equal(heat.hasRaster(), true); + assert.deepEqual(listed(), [ + { + id: 'firms-fixture:heat', + kind: 'image', + shown: true, + alpha: 0, + split: 'none', + order: 1, + }, + ]); + if (h.viewer) { + assert.equal(stack.length, 1, 'one imagery layer on the globe'); + assert.equal(stack.get(0).alpha, 0); + assert.equal(stack.get(0).show, true, 'a faded layer stays mapped'); + } else { + const drawn = h.engine.fake.imageryLayer('firms-fixture:heat'); + assert.equal(drawn.source.image.data, raster.rgba); + assert.deepEqual(drawn.source.rectangle, { + west: -180, + south: -90, + east: 180, + north: 90, + }); + } + heat.setOpacity(7); + heat.setShow(false); + assert.deepEqual( + [listed()[0].alpha, listed()[0].shown], + [1, false], + 'the opacity clamps; show hides', + ); + heat.setRaster({ ...raster, rgba: new Uint8ClampedArray(32) }); + assert.equal(listed().length, 1, 'a new raster replaces the image'); + assert.deepEqual([listed()[0].alpha, listed()[0].shown], [1, false]); + if (h.viewer) { + assert.equal(stack.length, 1); + assert.equal(stack.get(0).show, false); + } + heat.destroy(); + heat.destroy(); + assert.deepEqual(listed(), []); + if (h.viewer) assert.equal(stack.length, 0); + // The display takes its heatmap with it. + r.createHeatImagery().setRaster(raster); + assert.equal(listed().length, 1); + r.destroy(); + assert.deepEqual(listed(), []); + assert.equal(r.createHeatImagery(), null); + } finally { + h.destroy(); + restore(); + } + }); +} diff --git a/src/layers/firms/render/heatField.js b/src/layers/firms/render/heatField.js deleted file mode 100644 index 1e4afc432..000000000 --- a/src/layers/firms/render/heatField.js +++ /dev/null @@ -1,68 +0,0 @@ -// The Active Fires far-zoom heatmap behind one seam: one equirectangular -// RGBA raster over the whole globe (data/firmsHeatRaster.js), draped on the -// terrain and 3D tiles and blended between texels. The globe contract has no -// primitive for an image draped on the ground yet (engine.ground draws flat -// colours per item), so each engine draws it through its own backend: -// render/cesiumHeat.js (one imagery layer on the active imagery host) and -// render/navaraHeat.js (a surface pass in Navara's view), which the Navara -// mode loads and passes in, as Navara modules reach the app only through its -// import(). This is the only FIRMS module that reads engine.extensions. -// -// The field keeps what the layer asked for, so an engine with no backend -// (the fake engine) draws nothing and still answers. - -import { createFirmsCesiumHeat } from './cesiumHeat.js'; - -/** - * The heatmap with heatImagery.js's interface: setRaster, hasRaster, - * setShow, setOpacity (the hand-off fade, 0..1), rehome (follow the active - * imagery host) and destroy. - * @param {import('../../../globe/contract.js').GlobeEngine} engine - * @param {{ imageryHost?: () => ({ collection: object, kind: string } | null), - * navaraHeat?: (engine: object, navara: object) => object }} [options] - * `imageryHost` is the shell's imagery host (Cesium); `navaraHeat` the - * Navara backend's factory (render/navaraHeat.js). - */ -export function createFirmsHeatField(engine, { imageryHost, navaraHeat } = {}) { - const cesium = engine.extensions?.cesium; - const navara = engine.extensions?.navara; - const backend = cesium - ? createFirmsCesiumHeat(cesium, imageryHost) - : navara && navaraHeat - ? navaraHeat(engine, navara) - : null; - let raster = null, - show = true, - opacity = 1, - destroyed = false; - return { - setRaster(next) { - if (destroyed) return; - raster = next; - backend?.setRaster(next); - }, - /** True once a raster was handed over. */ - hasRaster: () => raster !== null, - setShow(value) { - if (destroyed) return; - show = Boolean(value); - backend?.setShow(show); - }, - setOpacity(value) { - if (destroyed) return; - opacity = Math.min(1, Math.max(0, Number(value) || 0)); - backend?.setOpacity(opacity); - }, - rehome() { - if (!destroyed) backend?.rehome(); - }, - /** What the layer asked for: { raster, show, opacity, drawn }. */ - state: () => ({ raster, show, opacity, drawn: backend !== null }), - destroy() { - if (destroyed) return; - destroyed = true; - backend?.destroy(); - raster = null; - }, - }; -} diff --git a/src/layers/firms/render/navaraHeat.js b/src/layers/firms/render/navaraHeat.js deleted file mode 100644 index 579c67afc..000000000 --- a/src/layers/firms/render/navaraHeat.js +++ /dev/null @@ -1,184 +0,0 @@ -// The Active Fires far-zoom heatmap on Navara: the same equirectangular heat -// raster the Cesium globe drapes as imagery (firmsHeatRaster.js), drawn over -// visible terrain and 3D tiles by one non-pickable classification pass. Only -// surface pixels take it, so the fire markers above stay untouched. The -// adapter has heatImagery.js's interface, so the layer drives both alike. -import { - DataTexture, - LinearFilter, - Matrix4, - RGBAFormat, - RepeatWrapping, - ClampToEdgeWrapping, - Uniform, - UnsignedByteType, -} from 'three'; -import { - groundSurfaceUniforms, - updateGroundSurface, -} from '../../shared/render/navaraGroundSurface.js'; -import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js'; - -const KEY = 'gevFirmsHeat'; - -// Row 0 of the raster is the north edge and column 0 is 180°W; the texture -// keeps that order (no flip), so v = 0 is north. -const fragmentShader = /* glsl */ ` -uniform sampler2D tAllDepth; -uniform sampler2D tSurfaceDepth; -uniform sampler2D tHeat; -uniform mat4 uInverseProjection; -uniform mat4 uInverseView; -uniform float uLogDepthFC; -uniform float uOpacity; -// WGS84 first eccentricity squared: geodetic latitude of a near-surface point. -const float E2=0.0066943799901413165; -vec3 toDisplay(vec3 c) { - return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c)); -} -vec3 toLinear(vec3 c) { - return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c)); -} -void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) { - outputColor=inputColor; - float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv)); - if(depth>=1.0) return; - float surface=texture2D(tSurfaceDepth,uv).r; - if(depth [ - ...groundSurfaceUniforms(), - ['tHeat', new Uniform(null)], - ['uOpacity', new Uniform(1)], - ['uInverseView', new Uniform(new Matrix4())], - ], - update() { - const owner = this.owner, - camera = this.view.camera.raw; - if (!this.effect || !owner.texture || !camera) return; - updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); - const u = this.effect.uniforms; - u.get('tHeat').value = owner.texture; - u.get('uOpacity').value = owner.opacity; - u.get('uInverseView').value.copy(camera.matrixWorld); - }, -}); - -/** - * The raster as a texture: linear between pixels, wrapping across 180°. Its - * sRGB bytes are read as they are and blended in display space, as the heat - * cells' colors are. - */ -function heatTexture(raster) { - const texture = new DataTexture( - raster.rgba, - raster.width, - raster.height, - RGBAFormat, - UnsignedByteType, - ); - texture.minFilter = LinearFilter; - texture.magFilter = LinearFilter; - texture.wrapS = RepeatWrapping; - texture.wrapT = ClampToEdgeWrapping; - texture.generateMipmaps = false; - texture.needsUpdate = true; - return texture; -} - -/** - * The heatmap drape on one Navara view, with heatImagery.js's interface: - * setRaster, hasRaster, setShow, setOpacity (the hand-off fade), rehome and - * destroy. Drawn only while shown, painted and above zero opacity. - */ -export function createFirmsHeatDrape( - engine, - { view, EffectDesc, NavaraEffect }, -) { - heatEffect.register(view, { EffectDesc, NavaraEffect }); - const owner = { texture: null, opacity: 1 }; - let raster = null, - show = true, - handle = null, - destroyed = false; - function sync() { - if (destroyed) return; - const visible = Boolean(owner.texture && show && owner.opacity > 0); - if (!handle && visible) - handle = view.addEffect({ [KEY]: { owner }, visible: true }); - else handle?.update({ visible }); - engine.render.request('firms-heat'); - } - return { - setRaster(next) { - if (destroyed) return; - raster = next; - owner.texture?.dispose(); - owner.texture = next ? heatTexture(next) : null; - sync(); - }, - /** True once a raster was handed over. */ - hasRaster() { - return raster !== null; - }, - setShow(value) { - show = Boolean(value); - sync(); - }, - /** Hand-off fade, 0..1. */ - setOpacity(value) { - owner.opacity = Math.min(1, Math.max(0, Number(value) || 0)); - sync(); - }, - /** Navara drapes on its own surface; there is no imagery host to follow. */ - rehome() {}, - destroy() { - if (destroyed) return; - destroyed = true; - handle?.delete(); - handle = null; - owner.texture?.dispose(); - owner.texture = null; - raster = null; - engine.render.request('firms-heat-destroy'); - }, - }; -} diff --git a/src/layers/firms/render/navaraHeat.test.mjs b/src/layers/firms/render/navaraHeat.test.mjs deleted file mode 100644 index 2fac083c8..000000000 --- a/src/layers/firms/render/navaraHeat.test.mjs +++ /dev/null @@ -1,80 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createFirmsHeatDrape } from './navaraHeat.js'; - -function harness() { - const effects = []; - const requests = []; - const view = { - registered: [], - registerEffect(key) { - view.registered.push(key); - }, - addEffect(config) { - const handle = { - config, - visible: config.visible, - deleted: false, - update(next) { - handle.visible = next.visible; - }, - delete() { - handle.deleted = true; - }, - }; - effects.push(handle); - return handle; - }, - }; - const engine = { render: { request: (reason) => requests.push(reason) } }; - const drape = createFirmsHeatDrape(engine, { - view, - EffectDesc: class {}, - NavaraEffect: class {}, - }); - return { drape, view, effects, requests }; -} - -const raster = (alpha) => ({ - width: 4, - height: 2, - rgba: new Uint8ClampedArray(4 * 2 * 4).fill(alpha), -}); - -test('the heatmap drape draws only a painted, shown raster above zero opacity', () => { - const h = harness(); - assert.deepEqual(h.view.registered, ['gevFirmsHeat']); - assert.equal(h.drape.hasRaster(), false); - assert.equal(h.effects.length, 0, 'nothing to draw before a raster'); - h.drape.setRaster(raster(200)); - assert.equal(h.drape.hasRaster(), true); - assert.equal(h.effects.length, 1); - const [effect] = h.effects; - const owner = effect.config.gevFirmsHeat.owner; - assert.equal(owner.texture.image.width, 4); - assert.equal(owner.texture.image.height, 2); - assert.equal(effect.visible, true); - h.drape.setOpacity(0); - assert.equal(effect.visible, false, 'faded out under the markers'); - h.drape.setOpacity(0.5); - assert.equal(effect.visible, true); - assert.equal(owner.opacity, 0.5); - h.drape.setShow(false); - assert.equal(effect.visible, false, 'the layer is off'); - h.drape.setShow(true); - // A new raster replaces the texture in the same pass. - const first = owner.texture; - let disposed = false; - first.dispose = () => (disposed = true); - h.drape.setRaster(raster(100)); - assert.equal(disposed, true); - assert.notEqual(owner.texture, first); - assert.equal(h.effects.length, 1); - assert.ok(h.requests.length > 0, 'every change asks for a frame'); - h.drape.destroy(); - assert.equal(effect.deleted, true); - assert.equal(owner.texture, null); - assert.equal(h.drape.hasRaster(), false); - h.drape.setRaster(raster(50)); - assert.equal(h.effects.length, 1, 'a destroyed drape draws nothing again'); -}); diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index f6b6168b2..383f66bb2 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -168,13 +168,11 @@ export function createRendering({ return clipped; } - /** The renderer's heat imagery adapter, created on first use. */ + /** The renderer's heat imagery layer, created on first use. */ function heatImagery() { layerState._heatImagery ??= - layerState._renderer?.createHeatImagery?.( - () => layerState._imageryHost?.() ?? null, - ) ?? null; + layerState._renderer?.createHeatImagery?.() ?? null; return layerState._heatImagery; } @@ -210,15 +208,14 @@ export function createRendering({ } /** - * Keep the heatmap draped on the current map stack and current with the - * shown set in every band; the hand-off fade decides how much of it shows. + * Keep the heatmap current with the shown set in every band (the engine + * keeps it on the map's surface); the hand-off fade decides how much of + * it shows. * @param {boolean} now The camera is in a far band. */ function prepareHeat(now) { const imagery = heatImagery(); - // The map stack may have changed since the last render. - imagery.rehome(); ensureHeatRaster(now); imagery.setShow(layerState._enabled); } diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 7415ac7b5..1dcdc66d0 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -148,14 +148,10 @@ export function createFirmsState({ services, config }) { layerState._handoffHeat = -1; - /** Imagery adapter (heatImagery.js), created on the first far render. */ + /** The heatmap's imagery layer (render/display.js), made on the first far render. */ layerState._heatImagery = null; - /** Shell-provided imagery host resolver; falls back to viewer.imageryLayers. */ - - layerState._imageryHost = null; - /** Camera idle snapshot so the throttled LOD check is ~free when parked. */ layerState._camSnapValid = false; diff --git a/src/layers/recentImagery/render/display.js b/src/layers/recentImagery/render/display.js new file mode 100644 index 000000000..66f542a7f --- /dev/null +++ b/src/layers/recentImagery/render/display.js @@ -0,0 +1,251 @@ +// The Recent Imagery drawing on the globe contract, the same on every +// engine: at most two GIBS tile layers (slot `a`, left of a swipe; slot `b`, +// right) on engine.imagery, bounded to the box and split at the swipe; the +// box tool's transient preview as one ground cell. The engine drapes the +// layers on the surface the map shows (globe or 3D tiles) and follows it; +// a slot shown again with the same day and box only restyles its layer, any +// other day or box replaces its source in place. Tile requests are capped +// per layer (`maxTileRequests`). + +import * as geo from '../../../geo/index.js'; +import { PRODUCTS, fitViewHeightM, gibsTemplate } from '../model.js'; + +const SLOT_IDS = ['a', 'b']; +const PREVIEW = Object.freeze({ + fill: [0.545, 0.914, 1, 0.12], + outline: { color: [0.545, 0.914, 1, 0.95], widthPx: 2 }, +}); +// What the layer calls a surface (src/maps/imageryHost.js kinds). +const HOST_KIND = { globe: 'globe', tiles: 'tileset', none: 'none' }; + +const boxKey = (box) => + [box.west, box.south, box.east, box.north].map(Number).join(','); +const alphaOf = (value) => { + const alpha = Number(value); + return Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1; +}; +const hasArea = (box) => + box != null && + [box.west, box.south, box.east, box.north].every(Number.isFinite) && + box.west < box.east && + box.south < box.north && + box.south >= -90 && + box.north <= 90; +const sideOf = (direction) => + direction === 'left' || direction === 'right' ? direction : 'none'; + +/** + * The renderer the Recent Imagery layer draws through (index.js). + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {{ tileTemplate?: (product: string, day: string) => string, + * credit?: string, maxTileRequests?: number }} [options] + */ +export function createRecentImageryDisplay( + engine, + { + tileTemplate = gibsTemplate, + credit = 'NASA GIBS', + maxTileRequests = 6, + } = {}, +) { + if (!engine?.imagery || !engine?.ground) + throw new TypeError('The Recent Imagery display needs a globe engine'); + /** @type {Record<'a'|'b', null | { layer: object, candidate: object, box: object, alpha: number, split: string }>} */ + const owned = { a: null, b: null }; + let host = { identity: null, kind: 'none' }; + let preview = null; + let destroyed = false; + + function sourceFor(candidate, box) { + const spec = PRODUCTS[candidate.product]; + return { + kind: 'tiles', + url: tileTemplate(candidate.product, candidate.day), + subdomains: ['a', 'b', 'c'], + minLevel: 0, + maxLevel: spec.maxLevel, + rectangle: { + west: box.west, + south: box.south, + east: box.east, + north: box.north, + }, + credit, + opaque: spec.format !== 'png', + maxRequests: maxTileRequests, + }; + } + + function hideSlot(slotId) { + const record = owned[slotId]; + if (!record) return; + owned[slotId] = null; + record.layer.destroy(); + } + + return { + /** Where the engine drapes imagery now, as the layer names hosts. */ + resolveHost: () => { + const kind = HOST_KIND[engine.imagery.surface()] ?? 'none'; + return { identity: kind === 'none' ? null : engine, kind }; + }, + /** The engine follows the surface itself; the host is the layer's view of it. */ + rebind(next) { + host = next ?? { identity: null, kind: 'none' }; + }, + /** + * Drape a candidate into a slot. The same candidate and box only + * restyle it; anything else draws the new source in its place. + * @param {'a'|'b'} slotId + * @param {object} candidate + * @param {object} box Degrees box. + * @param {{ alpha?: number, splitDirection?: 'left'|'right'|'none' }} [look] + * @returns {boolean} Whether a layer is now draped for the slot. + */ + showSlot( + slotId, + candidate, + box, + { alpha = 1, splitDirection = 'none' } = {}, + ) { + if ( + destroyed || + !SLOT_IDS.includes(slotId) || + !candidate?.key || + !PRODUCTS[candidate.product] || + !box + ) + return false; + if (host.kind === 'none') { + hideSlot(slotId); + return false; + } + const look = { alpha: alphaOf(alpha), split: sideOf(splitDirection) }; + const record = owned[slotId]; + if (!record) { + owned[slotId] = { + layer: engine.imagery.createLayer({ + id: `recent-imagery:${slotId}`, + source: sourceFor(candidate, box), + ...look, + }), + candidate: { ...candidate }, + box: { ...box }, + ...look, + }; + return true; + } + if ( + record.candidate.key !== candidate.key || + boxKey(record.box) !== boxKey(box) + ) { + record.layer.setSource(sourceFor(candidate, box)); + record.candidate = { ...candidate }; + record.box = { ...box }; + } + if (record.alpha !== look.alpha) record.layer.setAlpha(look.alpha); + if (record.split !== look.split) record.layer.setSplit(look.split); + Object.assign(record, look); + return true; + }, + /** @param {'a'|'b'} slotId */ + hideSlot, + /** + * @param {'a'|'b'} slotId + * @param {number} alpha 0–1 + */ + setAlpha(slotId, alpha) { + const record = owned[slotId]; + const next = alphaOf(alpha); + if (!record || record.alpha === next) return; + record.alpha = next; + record.layer.setAlpha(next); + }, + /** The swipe line, a fraction of the canvas width from the left. */ + setSplit(value) { + if (destroyed) return; + const fraction = Number(value); + if (Number.isFinite(fraction)) + engine.imagery.setSplitPosition(Math.max(0, Math.min(1, fraction))); + }, + /** + * The box tool's transient ground preview, or none; a box without area + * yet (the press before a drag) shows nothing. + */ + setBoxPreview(box) { + if (destroyed) return; + if (!hasArea(box)) { + preview?.show(false); + return; + } + preview ??= engine.ground.createSet({ + id: 'recent-imagery:preview', + kind: 'cell', + order: 10, + }); + preview.replace([ + { + west: box.west, + south: box.south, + east: box.east, + north: box.north, + ...PREVIEW, + }, + ]); + preview.commit(); + preview.show(true); + }, + viewCentre() { + const { width, height } = engine.host.size(), + hit = engine.camera.pickEllipsoid({ x: width / 2, y: height / 2 }); + const c = hit && geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84); + return c + ? { + lon: (c.longitude * 180) / Math.PI, + lat: (c.latitude * 180) / Math.PI, + } + : null; + }, + zoomToFit(target) { + const height = fitViewHeightM({ + ...engine.host.size(), + fovy: engine.camera.pose().fovy, + }); + engine.camera.flyTo({ + destination: geo.cartesian3.fromDegrees(target.lon, target.lat, height), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + duration: 1.2, + }); + return height; + }, + /** What is draped right now, per slot. */ + getOwned: () => + Object.fromEntries( + SLOT_IDS.map((slotId) => { + const record = owned[slotId]; + return [ + slotId, + record + ? { + key: record.candidate.key, + kind: host.kind, + alpha: record.alpha, + split: record.split, + } + : null, + ]; + }), + ), + /** Number of layers this display owns (never above two). */ + ownedCount: () => SLOT_IDS.filter((slotId) => owned[slotId]).length, + /** Remove everything and refuse further work. */ + destroy() { + if (destroyed) return; + destroyed = true; + for (const slotId of SLOT_IDS) hideSlot(slotId); + preview?.destroy(); + preview = null; + host = { identity: null, kind: 'none' }; + }, + }; +} diff --git a/src/layers/recentImagery/render/display.test.mjs b/src/layers/recentImagery/render/display.test.mjs new file mode 100644 index 000000000..8cc163e67 --- /dev/null +++ b/src/layers/recentImagery/render/display.test.mjs @@ -0,0 +1,233 @@ +// The Recent Imagery display on the fake engine and on the Cesium engine +// over a stub scene: the same calls, read back from each engine's own +// store (the fake's imagery layers, Cesium's imagery layers and their +// template providers). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; +import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs'; +import { BOX } from '../testDoubles.mjs'; +import { createRecentImageryDisplay } from './display.js'; + +const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' }; +const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' }; +const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' }; +const OTHER_BOX = { west: -97.9, south: 30.1, east: -97.6, north: 30.4 }; + +function fakeHarness() { + const engine = createFakeEngine(); + return { + name: 'the fake engine', + engine, + /** Each drawn layer as { url, maxLevel, opaque, rectangle, credit, alpha, split, shown }. */ + drawn: () => + engine.fake.imageryLayers().map((id) => { + const { source, alpha, split, shown } = engine.fake.imageryLayer(id); + return { + url: source.url, + maxLevel: source.maxLevel, + opaque: source.opaque, + rectangle: { ...source.rectangle }, + credit: source.credit, + alpha, + split, + shown, + }; + }), + hideSurface() { + engine.imagery.surface = () => 'none'; + }, + destroy: () => engine.destroy(), + }; +} + +function cesiumHarness() { + const { engine, viewer } = createCesiumMoversEngine(); + viewer.imageryLayers = new Cesium.ImageryLayerCollection(); + viewer.scene.globe.show = true; + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); + const side = (direction) => + ({ + [Cesium.SplitDirection.LEFT]: 'left', + [Cesium.SplitDirection.RIGHT]: 'right', + })[direction] ?? 'none'; + const degrees = (value) => Number(Cesium.Math.toDegrees(value).toFixed(9)); + return { + name: 'the Cesium engine', + engine, + drawn: () => + Array.from({ length: viewer.imageryLayers.length }, (_, i) => { + const layer = viewer.imageryLayers.get(i); + const provider = layer.imageryProvider; + const { west, south, east, north } = provider.rectangle; + return { + url: provider.url, + maxLevel: provider.maximumLevel, + opaque: !provider.hasAlphaChannel, + rectangle: { + west: degrees(west), + south: degrees(south), + east: degrees(east), + north: degrees(north), + }, + credit: provider.credit?.html ?? null, + alpha: layer.alpha, + split: side(layer.splitDirection), + shown: layer.show, + }; + }), + hideSurface() { + viewer.scene.globe.show = false; + }, + destroy: () => engine.destroy(), + }; +} + +for (const make of [fakeHarness, cesiumHarness]) { + const name = make().name; + + test(`${name}: a slot drapes one GIBS template bounded to the box; the same day only restyles it`, () => { + const h = make(); + try { + const display = createRecentImageryDisplay(h.engine); + display.rebind(display.resolveHost()); + assert.deepEqual(display.resolveHost(), { + identity: h.engine, + kind: 'globe', + }); + assert.equal(display.showSlot('a', S30, BOX, { alpha: 0.8 }), true); + const [drawn] = h.drawn(); + assert.match( + drawn.url, + /^https:\/\/gibs-\{s\}\.earthdata\.nasa\.gov\/.*HLS_S30.*\/2026-09-18\/GoogleMapsCompatible_Level12\/\{z\}\/\{y\}\/\{x\}\.png$/, + ); + assert.equal(drawn.maxLevel, 12); + assert.equal(drawn.opaque, false); + assert.equal(drawn.credit, 'NASA GIBS'); + assert.deepEqual(drawn.rectangle, BOX); + assert.equal(drawn.alpha, 0.8); + assert.deepEqual(display.getOwned().a, { + key: 'S30:2026-09-18', + kind: 'globe', + alpha: 0.8, + split: 'none', + }); + display.showSlot('a', S30, BOX, { alpha: 0.5, splitDirection: 'left' }); + assert.equal(h.drawn().length, 1); + assert.deepEqual([h.drawn()[0].alpha, h.drawn()[0].split], [0.5, 'left']); + // Another day or box draws in the slot's place. + display.showSlot('a', L30, OTHER_BOX, { + alpha: 0.5, + splitDirection: 'left', + }); + assert.equal(h.drawn().length, 1); + assert.match(h.drawn()[0].url, /HLS_L30/); + assert.deepEqual(h.drawn()[0].rectangle, OTHER_BOX); + // VIIRS is a level-9 JPEG overview with no alpha channel. + display.showSlot('b', VIIRS, BOX, { splitDirection: 'right' }); + const [, viirs] = h.drawn(); + assert.equal(viirs.maxLevel, 9); + assert.equal(viirs.opaque, true); + assert.equal(viirs.split, 'right'); + display.setAlpha('b', 7); + assert.equal(h.drawn()[1].alpha, 1, 'alpha clamps to 0–1'); + display.setSplit(0.3); + assert.equal(h.engine.imagery.splitPosition(), 0.3); + display.destroy(); + assert.deepEqual(h.drawn(), []); + assert.equal(display.showSlot('a', S30, BOX), false, 'destroyed'); + } finally { + h.destroy(); + } + }); + + test(`${name}: rapid replacement never owns more than two layers`, () => { + const h = make(); + try { + const display = createRecentImageryDisplay(h.engine); + display.rebind(display.resolveHost()); + const days = [S30, L30, VIIRS]; + for (let i = 0; i < 10; i += 1) { + display.showSlot('a', days[i % 3], BOX); + display.showSlot('b', days[(i + 1) % 3], BOX); + if (i % 4 === 0) display.hideSlot('a'); + assert.ok(h.drawn().length <= 2 && display.ownedCount() <= 2); + } + display.hideSlot('a'); + display.hideSlot('b'); + assert.deepEqual(h.drawn(), []); + } finally { + h.destroy(); + } + }); + + test(`${name}: without a surface a slot refuses and drops its layer`, () => { + const h = make(); + try { + const display = createRecentImageryDisplay(h.engine); + display.rebind(display.resolveHost()); + display.showSlot('a', S30, BOX); + h.hideSurface(); + const host = display.resolveHost(); + assert.deepEqual(host, { identity: null, kind: 'none' }); + display.rebind(host); + assert.equal(display.showSlot('a', S30, BOX), false); + assert.equal(display.ownedCount(), 0); + assert.deepEqual(h.drawn(), []); + } finally { + h.destroy(); + } + }); + + test(`${name}: replacement imagery uses source-owned tile URLs and credit`, () => { + const h = make(); + try { + const display = createRecentImageryDisplay(h.engine, { + tileTemplate: (product, day) => + `https://tiles.example/${product}/${day}/{z}/{x}/{y}`, + credit: 'Example imagery', + }); + display.rebind(display.resolveHost()); + assert.equal(display.showSlot('a', S30, BOX), true); + const [drawn] = h.drawn(); + assert.equal( + drawn.url, + 'https://tiles.example/S30/2026-09-18/{z}/{x}/{y}', + ); + assert.equal(drawn.credit, 'Example imagery'); + display.destroy(); + assert.deepEqual(h.drawn(), []); + } finally { + h.destroy(); + } + }); +} + +// The box tool's preview (the Navara box tool's; Cesium's tool draws its own). +test('the box preview is one ground cell, hidden with no box', () => { + const h = fakeHarness(); + try { + const display = createRecentImageryDisplay(h.engine); + const listed = () => + h.engine.ground + .list() + .find((entry) => entry.id === 'recent-imagery:preview'); + assert.equal(listed(), undefined, 'none before a box'); + display.setBoxPreview(BOX); + assert.deepEqual( + { ...listed(), id: undefined }, + { id: undefined, kind: 'cell', count: 1, shown: true, order: 10 }, + ); + display.setBoxPreview(null); + assert.equal(listed().shown, false); + // The press before a drag: a box without area shows nothing. + display.setBoxPreview(BOX); + display.setBoxPreview({ ...BOX, east: BOX.west, north: BOX.south }); + assert.equal(listed().shown, false); + display.destroy(); + assert.equal(listed(), undefined); + } finally { + h.destroy(); + } +}); diff --git a/src/layers/recentImagery/render/navara.js b/src/layers/recentImagery/render/navara.js deleted file mode 100644 index dadaf3244..000000000 --- a/src/layers/recentImagery/render/navara.js +++ /dev/null @@ -1,275 +0,0 @@ -import { CanvasTexture, LinearFilter } from 'three'; -import * as geo from '../../../geo/index.js'; -import { PRODUCTS, fitViewHeightM, gibsTemplate } from '../model.js'; -import { createImageryGround } from './navaraGround.js'; -import { - TILE_SIZE, - imageryTilePlan, - imageryTileUrl, - createImageryTileQueue, -} from './tiles.js'; - -const keyOf = (b) => [b.west, b.south, b.east, b.north].join(','); -const alphaOf = (x) => - Number.isFinite(Number(x)) ? Math.max(0, Math.min(1, Number(x))) : 1; -async function loadTile(url, signal) { - const response = await fetch(url, { signal }); - if (!response.ok) throw new Error(`Imagery tile HTTP ${response.status}`); - const blob = await response.blob(); - signal.throwIfAborted(); - return createImageBitmap(blob); -} - -/** Two bounded visible mosaics on actual terrain depth, with no paid source. */ -export function createRecentImageryNavaraRenderer(engine, options = {}) { - const { - tileTemplate = gibsTemplate, - credit = 'NASA GIBS', - load = loadTile, - } = options; - const owner = { slots: { a: null, b: null }, split: 0.5, preview: null }; - const ground = createImageryGround(engine, options, owner); - const queue = createImageryTileQueue({ load }); - let destroyed = false, - host = null, - releaseCredit = null; - const wake = () => engine.render.request('recent-imagery'); - function disposeAtlas(record) { - const atlas = record.atlas; - record.atlas = null; - if (!atlas) return; - for (const c of atlas.controllers) c.abort(); - atlas.texture.dispose(); - atlas.canvas.width = atlas.canvas.height = 0; - queue.prune(); - } - function updateTiles(record) { - const pose = engine.camera.pose(), - size = engine.host.size(); - const metresPerPixel = - (2 * Math.max(1, pose.cartographic.height) * Math.tan(pose.fovy / 2)) / - Math.max(1, size.height); - const level = Math.min( - PRODUCTS[record.candidate.product].maxLevel, - Math.ceil( - Math.log2( - (156543.033928 * Math.cos(pose.cartographic.latitude)) / - metresPerPixel, - ), - ), - ); - const plan = imageryTilePlan( - record.box, - engine.camera.viewRectangle(), - level, - ); - if ((record.atlas?.plan.key || null) === (plan?.key || null)) return; - disposeAtlas(record); - if (!plan) { - wake(); - return; - } - const canvas = document.createElement('canvas'); - canvas.width = plan.width * TILE_SIZE; - canvas.height = plan.height * TILE_SIZE; - const texture = new CanvasTexture(canvas); - texture.flipY = false; - texture.minFilter = texture.magFilter = LinearFilter; - texture.generateMipmaps = false; - const atlas = { - plan, - canvas, - texture, - controllers: new Set(), - loaded: 0, - failed: 0, - }; - record.atlas = atlas; - const context = canvas.getContext('2d'); - const template = tileTemplate( - record.candidate.product, - record.candidate.day, - ), - tasks = []; - for (let y = 0; y < plan.height; y++) - for (let x = 0; x < plan.width; x++) - tasks.push({ - url: imageryTileUrl(template, plan.x + x, plan.y + y, plan.z), - controllers: atlas.controllers, - live: () => !destroyed && record.atlas === atlas, - draw(image) { - context.drawImage( - image, - x * TILE_SIZE, - y * TILE_SIZE, - TILE_SIZE, - TILE_SIZE, - ); - atlas.loaded++; - texture.needsUpdate = true; - wake(); - }, - fail() { - atlas.failed++; - }, - }); - queue.add(tasks); - wake(); - } - function sync() { - ground.sync(); - const shown = owner.slots.a || owner.slots.b; - if (shown && !releaseCredit) - releaseCredit = engine.credits.addStatic(credit); - else if (!shown && releaseCredit) { - releaseCredit(); - releaseCredit = null; - } - wake(); - } - function hideSlot(id) { - const record = owner.slots[id]; - if (!record) return; - owner.slots[id] = null; - disposeAtlas(record); - sync(); - } - const offCamera = engine.camera.changed.addEventListener(() => { - for (const record of Object.values(owner.slots)) - if (record) updateTiles(record); - }); - return { - resolveHost: () => ({ - identity: engine, - kind: engine.maps.current()?.photoreal ? 'tileset' : 'globe', - }), - rebind(next) { - host = next; - }, - showSlot(id, candidate, box, { alpha = 1, splitDirection = 'none' } = {}) { - if ( - destroyed || - !['a', 'b'].includes(id) || - !candidate?.key || - !PRODUCTS[candidate.product] || - !box || - !host?.identity - ) - return false; - let record = owner.slots[id]; - if ( - record && - (record.candidate.key !== candidate.key || - keyOf(record.box) !== keyOf(box)) - ) { - hideSlot(id); - record = null; - } - if (!record) { - record = owner.slots[id] = { - candidate: { ...candidate }, - box: { ...box }, - alpha: alphaOf(alpha), - split: splitDirection, - atlas: null, - }; - updateTiles(record); - sync(); - } else if ( - record.alpha !== alphaOf(alpha) || - record.split !== splitDirection - ) { - record.alpha = alphaOf(alpha); - record.split = splitDirection; - wake(); - } - return true; - }, - hideSlot, - setAlpha(id, alpha) { - const r = owner.slots[id], - next = alphaOf(alpha); - if (r && r.alpha !== next) { - r.alpha = next; - wake(); - } - }, - setSplit(value) { - if (owner.split !== value) { - owner.split = value; - wake(); - } - }, - setBoxPreview(box) { - owner.preview = box ? { ...box } : null; - ground.sync(); - wake(); - }, - viewCentre() { - const { width, height } = engine.host.size(), - hit = engine.camera.pickEllipsoid({ x: width / 2, y: height / 2 }); - const c = hit && geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84); - return c - ? { - lon: (c.longitude * 180) / Math.PI, - lat: (c.latitude * 180) / Math.PI, - } - : null; - }, - zoomToFit(target) { - const height = fitViewHeightM({ - ...engine.host.size(), - fovy: engine.camera.pose().fovy, - }); - engine.camera.flyTo({ - destination: geo.cartesian3.fromDegrees(target.lon, target.lat, height), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - duration: 1.2, - }); - return height; - }, - getOwned: () => - Object.fromEntries( - Object.entries(owner.slots).map(([id, r]) => [ - id, - r - ? { - key: r.candidate.key, - kind: 'globe', - alpha: r.alpha, - split: r.split, - } - : null, - ]), - ), - ownedCount: () => Object.values(owner.slots).filter(Boolean).length, - diagnostics: () => ({ - ...queue.diagnostics(), - slots: Object.fromEntries( - Object.entries(owner.slots).map(([id, r]) => [ - id, - r?.atlas - ? { - level: r.atlas.plan.z, - tiles: r.atlas.plan.width * r.atlas.plan.height, - loaded: r.atlas.loaded, - failed: r.atlas.failed, - } - : null, - ]), - ), - }), - destroy() { - if (destroyed) return; - destroyed = true; - offCamera(); - hideSlot('a'); - hideSlot('b'); - queue.destroy(); - ground.destroy(); - releaseCredit?.(); - releaseCredit = null; - owner.preview = null; - }, - }; -} diff --git a/src/layers/recentImagery/render/navara.test.mjs b/src/layers/recentImagery/render/navara.test.mjs deleted file mode 100644 index 0b31eb597..000000000 --- a/src/layers/recentImagery/render/navara.test.mjs +++ /dev/null @@ -1,126 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createRecentImageryNavaraRenderer } from './navara.js'; - -test('slot replacement, alpha-only edits, and destruction own textures, credits and late decodes', async () => { - const previousDocument = globalThis.document, - canvases = [], - jobs = [], - draws = []; - globalThis.document = { - createElement() { - const c = { - width: 0, - height: 0, - getContext: () => ({ drawImage: (i) => draws.push(i) }), - }; - canvases.push(c); - return c; - }, - }; - let owner, - removed = 0, - credits = 0, - textureDisposals = 0, - off = 0; - const callbacks = []; - const engine = { - camera: { - changed: { - addEventListener(fn) { - callbacks.push(fn); - return () => off++; - }, - }, - pose: () => ({ - cartographic: { height: 12000, latitude: 0.5 }, - fovy: 0.7, - }), - viewRectangle: () => null, - }, - host: { - canvas: { width: 1280 }, - size: () => ({ width: 1280, height: 800 }), - }, - maps: { current: () => ({ photoreal: null }) }, - render: { request() {} }, - credits: { - addStatic() { - credits++; - return () => credits--; - }, - }, - }; - const view = { - registerEffect() {}, - addEffect(config) { - owner = config.gevRecentImagery.owner; - return { - update() {}, - delete() { - removed++; - }, - }; - }, - }; - const renderer = createRecentImageryNavaraRenderer(engine, { - view, - EffectDesc: class {}, - NavaraEffect: class {}, - load: (url, signal) => - new Promise((resolve) => jobs.push({ url, signal, resolve })), - }); - try { - const box = { west: -97.77, east: -97.71, south: 30.25, north: 30.29 }; - const candidate = { - key: 'S30:2026-10-28', - product: 'S30', - day: '2026-10-28', - }; - renderer.rebind(renderer.resolveHost()); - renderer.showSlot('a', candidate, box); - await new Promise((resolve) => setImmediate(resolve)); - const texture = owner.slots.a.atlas.texture, - canvas = owner.slots.a.atlas.canvas; - texture.addEventListener('dispose', () => textureDisposals++); - const originalJobs = jobs.length; - renderer.showSlot('a', candidate, box, { - alpha: 0.4, - splitDirection: 'left', - }); - renderer.setSplit(0.3); - callbacks[0](); - assert.equal(owner.slots.a.atlas.texture, texture); - assert.equal(jobs.length, originalJobs); - assert.equal(owner.split, 0.3); - assert.equal(renderer.getOwned().a.alpha, 0.4); - assert.equal(credits, 1); - renderer.showSlot( - 'a', - { ...candidate, key: 'new', day: '2026-10-27' }, - box, - ); - assert.equal(textureDisposals, 1); - assert.equal(canvas.width, 0); - assert.ok(jobs.every((j) => j.signal.aborted)); - let closed = 0; - for (const j of jobs) j.resolve({ close: () => closed++ }); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(draws.length, 0); - assert.equal(closed, originalJobs); - renderer.destroy(); - renderer.destroy(); - for (const j of jobs.slice(originalJobs)) - j.resolve({ close: () => closed++ }); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(renderer.ownedCount(), 0); - assert.equal(credits, 0); - assert.equal(removed, 1); - assert.equal(off, 1); - assert.equal(renderer.diagnostics().active, 0); - assert.ok(canvases.every((c) => c.width === 0)); - } finally { - renderer.destroy(); - globalThis.document = previousDocument; - } -}); diff --git a/src/layers/recentImagery/render/navaraGround.js b/src/layers/recentImagery/render/navaraGround.js index adebf0f13..aea154af9 100644 --- a/src/layers/recentImagery/render/navaraGround.js +++ b/src/layers/recentImagery/render/navaraGround.js @@ -5,8 +5,10 @@ import { } from '../../shared/render/navaraGroundSurface.js'; import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js'; -const KEY = 'gevRecentImagery', - effects = new Map(); +// Two images under a swipe on a Navara surface pass. Recent Imagery draws +// through engine.imagery now; the Nepal event imagery (bhoteKoshi) still +// draws here until it moves there too. +const effects = new Map(); const shader = /* glsl */ ` uniform sampler2D tAllDepth,tSurfaceDepth,tA,tB; uniform mat4 uInverseProjection,uWorld; @@ -57,19 +59,17 @@ void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) { const boxVector = (b) => b ? [b.west, b.south, b.east, b.north] : [0, 0, 0, 0]; -// Another key (the Nepal event imagery) draws over the recent imagery. +// Over engine.imagery's layers (src/globe/navara/imagery/pass.js). const imageryEffect = (key) => defineGroundEffect({ key, - name: 'GevRecentImagery', + name: 'GevEventImagery', fragmentShader: shader, - insertAfter: - key === KEY ? ['toneMapping'] : ['gevRecentImagery', 'toneMapping'], + insertAfter: ['gevImagery', 'toneMapping'], insertBefore: [ - 'gevNepalEvent', 'gevObservedWeather', 'gevEarthquakeGround', - 'gevFirmsGround', + 'gevGround', 'gevPreSharpenOverlay', 'gevSharpen', 'smaa', @@ -122,7 +122,7 @@ export function createImageryGround( engine, { view, EffectDesc, NavaraEffect }, owner, - key = KEY, + key, ) { if (!effects.has(key)) effects.set(key, imageryEffect(key)); effects.get(key).register(view, { EffectDesc, NavaraEffect }); diff --git a/src/layers/recentImagery/render/tiles.js b/src/layers/recentImagery/render/tiles.js deleted file mode 100644 index 54eaea847..000000000 --- a/src/layers/recentImagery/render/tiles.js +++ /dev/null @@ -1,113 +0,0 @@ -const LIMIT = 85.05112878; -export const TILE_SIZE = 256; -const MAX_TILE_SIDE = 8; -export const mercatorX = (lon) => (lon + 180) / 360; -export const mercatorY = (lat) => { - const r = (Math.max(-LIMIT, Math.min(LIMIT, lat)) * Math.PI) / 180; - return (1 - Math.log(Math.tan(Math.PI / 4 + r / 2)) / Math.PI) / 2; -}; - -/** At most 8×8 visible source tiles per slot, never a global mosaic. */ -export function imageryTilePlan(box, rectangle, level) { - let area = { ...box }; - if (rectangle && rectangle.east >= rectangle.west) { - const degrees = 180 / Math.PI; - area = { - west: Math.max(box.west, rectangle.west * degrees), - east: Math.min(box.east, rectangle.east * degrees), - south: Math.max(box.south, rectangle.south * degrees), - north: Math.min(box.north, rectangle.north * degrees), - }; - } - if (area.west >= area.east || area.south >= area.north) return null; - for (let z = Math.max(0, Math.floor(level)); z >= 0; z--) { - const n = 2 ** z; - const x = Math.max(0, Math.floor(mercatorX(area.west) * n)); - const y = Math.max(0, Math.floor(mercatorY(area.north) * n)); - const right = Math.min(n - 1, Math.ceil(mercatorX(area.east) * n) - 1); - const bottom = Math.min(n - 1, Math.ceil(mercatorY(area.south) * n) - 1); - const width = right - x + 1, - height = bottom - y + 1; - if (width <= MAX_TILE_SIDE && height <= MAX_TILE_SIDE) - return { - key: [z, x, y, width, height].join('/'), - z, - x, - y, - width, - height, - bounds: [x / n, y / n, width / n, height / n], - }; - } - return null; -} - -export function imageryTileUrl(template, x, y, z) { - return template - .replaceAll('{x}', String(x)) - .replaceAll('{y}', String(y)) - .replaceAll('{z}', String(z)) - .replaceAll('{s}', ['a', 'b', 'c'][(x + y) % 3]); -} - -/** Cancellation holds its admission slot until fetch AND decode settle. */ -export function createImageryTileQueue({ load, limit = 6 }) { - let active = 0, - peak = 0, - destroyed = false; - const pending = [], - controllers = new Set(); - function pump() { - while (!destroyed && active < limit && pending.length) { - const task = pending.shift(); - if (!task.live()) continue; - const controller = new AbortController(); - controllers.add(controller); - task.controllers.add(controller); - active++; - peak = Math.max(peak, active); - Promise.resolve() - .then(() => load(task.url, controller.signal)) - .then((image) => { - try { - if (!destroyed && !controller.signal.aborted && task.live()) - task.draw(image); - } finally { - image?.close?.(); - } - }) - .catch((error) => { - if (!destroyed && !controller.signal.aborted && task.live()) - task.fail(error); - }) - .finally(() => { - controllers.delete(controller); - task.controllers.delete(controller); - active--; - pump(); - }); - } - } - return { - add(tasks) { - if (!destroyed) { - pending.push(...tasks); - pump(); - } - }, - prune() { - 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; - for (const c of controllers) c.abort(); - }, - }; -} diff --git a/src/layers/recentImagery/render/tiles.test.mjs b/src/layers/recentImagery/render/tiles.test.mjs deleted file mode 100644 index 9bd95ae40..000000000 --- a/src/layers/recentImagery/render/tiles.test.mjs +++ /dev/null @@ -1,90 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - imageryTilePlan, - imageryTileUrl, - createImageryTileQueue, - mercatorX, - mercatorY, -} from './tiles.js'; - -test('visible imagery tiles stay bounded and contain the intersected geographic area', () => { - const box = { west: -100, east: -90, south: 25, north: 35 }; - for (const level of [0, 5, 9, 12]) { - const plan = imageryTilePlan(box, null, level), - n = 2 ** plan.z; - assert.ok(plan.width <= 8 && plan.height <= 8); - assert.ok( - plan.x <= mercatorX(box.west) * n && - plan.x + plan.width >= mercatorX(box.east) * n, - ); - assert.ok( - plan.y <= mercatorY(box.north) * n && - plan.y + plan.height >= mercatorY(box.south) * n, - ); - } - const r = (x) => (x * Math.PI) / 180; - const view = { - west: r(-97.75), - east: r(-97.73), - south: r(30.26), - north: r(30.28), - }; - const plan = imageryTilePlan(box, view, 12); - assert.equal(plan.z, 12); - assert.ok(plan.width * plan.height < 8); - assert.equal( - imageryTilePlan(box, { west: 0, east: 0.1, south: 0, north: 0.1 }, 12), - null, - ); -}); - -test('source tile addressing retains XYZ order and deterministic subdomain', () => { - assert.equal( - imageryTileUrl('https://gibs-{s}.example/{z}/{y}/{x}.png', 4, 7, 9), - 'https://gibs-c.example/9/7/4.png', - ); -}); - -test('six admissions include slow aborted decodes; stale images close without drawing or errors', async () => { - const pending = [], - drawn = [], - failed = [], - closed = []; - let live = true; - const controllers = new Set(); - const queue = createImageryTileQueue({ - load: (url, signal) => - new Promise((resolve) => pending.push({ url, signal, resolve })), - }); - const task = (id) => ({ - url: String(id), - controllers, - live: () => live, - draw: () => drawn.push(id), - fail: () => failed.push(id), - }); - 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); - 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); - for (const p of pending) p.resolve({ close: () => closed.push(p.url) }); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(pending.length, 7); - assert.equal(drawn.length, 0); - assert.equal(closed.length, 6); - queue.destroy(); - replacement = false; - pending[6].resolve({ close: () => closed.push('last') }); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(drawn, []); - assert.deepEqual(failed, []); - assert.equal(closed.length, 7); - assert.deepEqual(queue.diagnostics(), { active: 0, queued: 0, peak: 6 }); -}); diff --git a/src/layers/recentImagery/rendering.js b/src/layers/recentImagery/rendering.js deleted file mode 100644 index b241d0f52..000000000 --- a/src/layers/recentImagery/rendering.js +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Draped-imagery renderer for the Recent Imagery layer: at most two GIBS - * imagery layers (slot `a`, left of a swipe; slot `b`, right) on whichever - * host the map stack offers. Against the basemap slot `a` splits left and - * slot `b` stays empty. Every layer is removed from the collection that owns it, so a - * host switch or a replaced image never strands one. Tile requests pass a - * small semaphore of our own; Cesium's global request limits stay untouched. - */ -import * as Cesium from 'cesium'; -import { PRODUCTS, gibsTemplate } from './model.js'; - -const SLOT_IDS = ['a', 'b']; -const SPLIT_ENUM = { left: 'LEFT', right: 'RIGHT', none: 'NONE' }; -const NO_HOST = Object.freeze({ collection: null, kind: 'none' }); - -/** Render reason for a deferred or settled tile request. */ -export const TILE_RENDER_REASON = 'recent-imagery-tiles'; -/** Delay before the one frame that retries tiles Cesium itself deferred. */ -export const TILE_RETRY_DELAY_MS = 250; - -function clampAlpha(value) { - const alpha = Number(value); - return Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1; -} - -const boxKey = (box) => - [box.west, box.south, box.east, box.north].map(Number).join(','); - -/** - * Gate a provider's `requestImage` through the semaphore. Returning - * `undefined` tells Cesium to retry on a later frame, but under - * `requestRenderMode` there is no later frame unless something asks — and a - * deferral must never ask for one itself: the retried tile would defer again - * and ask again, keeping the scene rendering every frame. So a request that - * finds our semaphore full asks for nothing (the request holding the slot - * asks for one frame when it settles), and a request the provider itself - * deferred goes through the one coalesced delayed 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; - admission.inFlight += 1; - const release = () => { - admission.inFlight -= 1; - }; - let promise; - try { - promise = original(x, y, level, request); - } catch (error) { - release(); - throw error; - } - if (!promise) { - release(); - retry.schedule(); - return undefined; - } - return Promise.resolve(promise).finally(() => { - release(); - requestRender(TILE_RENDER_REASON); - }); - }; - return provider; -} - -/** - * Create the renderer. - * @param {{ cesium?: object, maxTileRequests?: number, requestRender?: (reason: string) => void, setTimeoutImpl?: Function, clearTimeoutImpl?: Function }} [options] - */ -export function createRecentImageryRenderer({ - cesium = Cesium, - tileTemplate = gibsTemplate, - credit = 'NASA GIBS', - maxTileRequests = 6, - requestRender = () => {}, - setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms), - clearTimeoutImpl = (id) => globalThis.clearTimeout(id), -} = {}) { - let host = NO_HOST; - let destroyed = false; - const admission = { limit: maxTileRequests, inFlight: 0 }; - /** @type {Record<'a'|'b', null | { layer: object, collection: object, candidate: object, box: object, alpha: number, split: string }>} */ - const owned = { a: null, b: null }; - - // One timer for both slots: any number of upstream deferrals while it is - // armed share the same single retry frame. - let retryTimer = null; - const retry = { - schedule() { - if (retryTimer !== null) return; - retryTimer = setTimeoutImpl(() => { - retryTimer = null; - if (!destroyed) requestRender(TILE_RENDER_REASON); - }, TILE_RETRY_DELAY_MS); - }, - cancel() { - if (retryTimer !== null) clearTimeoutImpl(retryTimer); - retryTimer = null; - }, - }; - - function createProvider(candidate, box) { - const spec = PRODUCTS[candidate.product]; - const provider = new cesium.UrlTemplateImageryProvider({ - url: tileTemplate(candidate.product, candidate.day), - subdomains: ['a', 'b', 'c'], - minimumLevel: 0, - maximumLevel: spec.maxLevel, - tilingScheme: new cesium.WebMercatorTilingScheme(), - rectangle: cesium.Rectangle.fromDegrees( - box.west, - box.south, - box.east, - box.north, - ), - credit, - hasAlphaChannel: spec.format === 'png', - }); - return admitTileRequests(provider, admission, requestRender, retry); - } - - function removeOwned(slotId) { - const record = owned[slotId]; - if (!record) return false; - owned[slotId] = null; - try { - record.collection.remove(record.layer, true); - } catch { - /* the collection is already gone */ - } - return true; - } - - function applyLook(record) { - record.layer.alpha = record.alpha; - record.layer.splitDirection = - cesium.SplitDirection?.[SPLIT_ENUM[record.split] || 'NONE'] ?? 0; - } - - function mount(slotId, { candidate, box, alpha, split }) { - const layer = host.collection.addImageryProvider( - createProvider(candidate, box), - ); - owned[slotId] = { - layer, - collection: host.collection, - candidate, - box, - alpha, - split, - }; - applyLook(owned[slotId]); - } - - return { - /** - * Drape a candidate into a slot. The same candidate on the same host only - * updates its look; anything else replaces the slot's layer. - * @param {'a'|'b'} slotId - * @param {object} candidate - * @param {object} box Degrees box. - * @param {{ alpha?: number, splitDirection?: 'left'|'right'|'none' }} [look] - * @returns {boolean} Whether a layer is now draped for the slot. - */ - showSlot( - slotId, - candidate, - box, - { alpha = 1, splitDirection = 'none' } = {}, - ) { - if (destroyed || !candidate?.key || !box) return false; - if (!host.collection) { - removeOwned(slotId); - return false; - } - const current = owned[slotId]; - const look = { alpha: clampAlpha(alpha), split: splitDirection }; - if ( - current && - current.collection === host.collection && - current.candidate.key === candidate.key && - boxKey(current.box) === boxKey(box) - ) { - if (current.alpha !== look.alpha || current.split !== look.split) { - Object.assign(current, look); - applyLook(current); - requestRender('recent-imagery-look'); - } - return true; - } - removeOwned(slotId); - mount(slotId, { candidate, box, ...look }); - requestRender('recent-imagery-show'); - return true; - }, - - /** @param {'a'|'b'} slotId */ - hideSlot(slotId) { - if (removeOwned(slotId)) requestRender('recent-imagery-hide'); - }, - - /** - * @param {'a'|'b'} slotId - * @param {number} alpha 0–1 - */ - setAlpha(slotId, alpha) { - const record = owned[slotId]; - const next = clampAlpha(alpha); - if (!record || record.alpha === next) return; - record.alpha = next; - applyLook(record); - requestRender('recent-imagery-alpha'); - }, - - /** - * Move every owned layer to a new host. Layers are rebuilt there, never - * re-parented, so the old collection is left as it was found. - * @param {{ collection: object | null, kind: string }} nextHost - */ - rebind(nextHost) { - host = nextHost || NO_HOST; - let changed = false; - for (const slotId of SLOT_IDS) { - const record = owned[slotId]; - if (!record || record.collection === host.collection) continue; - removeOwned(slotId); - changed = true; - if (host.collection && !destroyed) mount(slotId, record); - } - if (changed) requestRender('recent-imagery-rebind'); - }, - - /** What is draped right now, per slot. */ - getOwned() { - const out = {}; - for (const slotId of SLOT_IDS) { - const record = owned[slotId]; - out[slotId] = record - ? { - key: record.candidate.key, - kind: host.kind, - alpha: record.alpha, - split: record.split, - } - : null; - } - return out; - }, - - /** Number of layers this renderer owns (never above two). */ - ownedCount() { - return SLOT_IDS.filter((slotId) => owned[slotId]).length; - }, - - /** Remove everything and refuse further work. */ - destroy() { - if (destroyed) return; - destroyed = true; - retry.cancel(); - let changed = false; - for (const slotId of SLOT_IDS) changed = removeOwned(slotId) || changed; - host = NO_HOST; - if (changed) requestRender('recent-imagery-destroy'); - }, - }; -} diff --git a/src/layers/recentImagery/rendering.test.mjs b/src/layers/recentImagery/rendering.test.mjs deleted file mode 100644 index c1d38cacc..000000000 --- a/src/layers/recentImagery/rendering.test.mjs +++ /dev/null @@ -1,229 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - TILE_RENDER_REASON, - TILE_RETRY_DELAY_MS, - createRecentImageryRenderer, -} from './rendering.js'; -import { BOX, manualTimers } from './testDoubles.mjs'; - -/** A provider whose tile requests stay pending until the test settles them. */ -class UrlTemplateImageryProvider { - constructor(options) { - this.options = options; - this.calls = []; - this.pending = []; - } - requestImage(x, y, level) { - this.calls.push([x, y, level]); - return new Promise((resolve) => this.pending.push(resolve)); - } -} - -function fakeCesium(requestImage) { - class Provider extends UrlTemplateImageryProvider {} - if (requestImage) Provider.prototype.requestImage = requestImage; - return { - UrlTemplateImageryProvider: Provider, - WebMercatorTilingScheme: class {}, - Rectangle: { - fromDegrees: (west, south, east, north) => ({ west, south, east, north }), - }, - SplitDirection: { LEFT: -1, NONE: 0, RIGHT: 1 }, - }; -} - -function fakeCollection() { - const layers = []; - return { - layers, - addImageryProvider(provider) { - const layer = { provider, alpha: 1, splitDirection: 0 }; - layers.push(layer); - return layer; - }, - remove(layer, destroy) { - layers.splice(layers.indexOf(layer), 1); - layer.destroyed = destroy; - }, - }; -} - -const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' }; -const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' }; -const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' }; - -function fixture({ requestImage, maxTileRequests, ...options } = {}) { - const renders = []; - const timers = manualTimers(); - const renderer = createRecentImageryRenderer({ - ...options, - cesium: fakeCesium(requestImage), - maxTileRequests, - requestRender: (reason) => renders.push(reason), - setTimeoutImpl: timers.setTimeoutImpl, - clearTimeoutImpl: timers.clearTimeoutImpl, - }); - const globe = fakeCollection(); - renderer.rebind({ collection: globe, kind: 'globe' }); - const tileFrames = () => - renders.filter((reason) => reason === TILE_RENDER_REASON).length; - return { renderer, globe, renders, timers, tileFrames }; -} - -test('a slot drapes one GIBS provider bounded to the box; the same day only restyles it', () => { - const { renderer, globe, renders } = fixture(); - assert.equal(renderer.showSlot('a', S30, BOX, { alpha: 0.8 }), true); - const [layer] = globe.layers; - assert.match( - layer.provider.options.url, - /^https:\/\/gibs-\{s\}\.earthdata\.nasa\.gov\/.*HLS_S30.*\/2026-09-18\/GoogleMapsCompatible_Level12\/\{z\}\/\{y\}\/\{x\}\.png$/, - ); - assert.deepEqual(layer.provider.options.subdomains, ['a', 'b', 'c']); - assert.equal(layer.provider.options.credit, 'NASA GIBS'); - assert.deepEqual(layer.provider.options.rectangle, BOX); - assert.equal(layer.alpha, 0.8); - assert.deepEqual(renders, ['recent-imagery-show']); - assert.deepEqual(renderer.getOwned().a, { - key: 'S30:2026-09-18', - kind: 'globe', - alpha: 0.8, - split: 'none', - }); - renderer.showSlot('a', S30, BOX, { alpha: 0.5, splitDirection: 'left' }); - assert.deepEqual(globe.layers, [layer], 'the layer is reused'); - assert.equal(layer.splitDirection, -1); - // A different day replaces the layer in its own collection. - renderer.showSlot('a', L30, BOX); - assert.equal(layer.destroyed, true); - assert.equal(globe.layers.length, 1); - // VIIRS is a level-9 JPEG overview with no alpha channel. - renderer.showSlot('b', VIIRS, BOX, { splitDirection: 'right' }); - assert.equal(globe.layers[1].provider.options.maximumLevel, 9); - assert.equal(globe.layers[1].provider.options.hasAlphaChannel, false); - assert.equal(globe.layers[1].splitDirection, 1); - renderer.setAlpha('b', 7); - assert.equal(globe.layers[1].alpha, 1, 'alpha clamps to 0–1'); -}); - -test('rapid replacement never owns more than two layers', () => { - const { renderer, globe } = fixture(); - const days = [S30, L30, VIIRS]; - for (let i = 0; i < 10; i += 1) { - renderer.showSlot('a', days[i % 3], BOX); - renderer.showSlot('b', days[(i + 1) % 3], BOX); - if (i % 4 === 0) renderer.hideSlot('a'); - assert.ok(globe.layers.length <= 2 && renderer.ownedCount() <= 2); - } - renderer.hideSlot('a'); - renderer.hideSlot('b'); - assert.equal(globe.layers.length, 0); -}); - -test('rebind rebuilds the owned layers on the new host and leaves the old one empty', () => { - const { renderer, globe } = fixture(); - renderer.showSlot('a', S30, BOX, { alpha: 0.6, splitDirection: 'left' }); - renderer.showSlot('b', L30, BOX, { splitDirection: 'right' }); - const tileset = fakeCollection(); - renderer.rebind({ collection: tileset, kind: 'tileset' }); - assert.equal(globe.layers.length, 0); - assert.deepEqual( - tileset.layers.map((layer) => [layer.alpha, layer.splitDirection]), - [ - [0.6, -1], - [1, 1], - ], - ); - assert.equal(renderer.getOwned().a.kind, 'tileset'); - renderer.rebind({ collection: null, kind: 'none' }); - assert.equal(tileset.layers.length, 0); - assert.equal(renderer.showSlot('a', S30, BOX), false); - renderer.rebind({ collection: globe, kind: 'globe' }); - renderer.showSlot('a', S30, BOX); - renderer.destroy(); - assert.equal(globe.layers.length, 0); - assert.equal(renderer.showSlot('a', S30, BOX), false, 'destroyed'); -}); - -test('above the tile limit a request defers silently; its settlement asks for exactly one frame', async () => { - const { renderer, globe, timers, tileFrames } = fixture({ - maxTileRequests: 1, - }); - renderer.showSlot('a', S30, BOX); - const { provider } = globe.layers[0]; - const first = provider.requestImage(0, 0, 1); - assert.ok(first instanceof Promise); - for (let i = 1; i <= 120; i += 1) - assert.equal(provider.requestImage(i, 0, 1), undefined); - assert.equal(provider.calls.length, 1, 'deferred requests never go out'); - assert.equal(tileFrames(), 0, 'a deferral asks for no frame'); - assert.equal(timers.armed(), 0); - provider.pending[0]('tile'); - await first; - assert.equal(tileFrames(), 1, 'the settlement asks for the retry frame'); - assert.ok(provider.requestImage(200, 0, 1) instanceof Promise); -}); - -test('upstream deferrals from both slots share one delayed retry frame; destroy disarms it', () => { - let throwNext = false; - const { renderer, globe, timers, tileFrames } = fixture({ - maxTileRequests: 1, - requestImage() { - if (throwNext) throw new Error('boom'); - return undefined; - }, - }); - renderer.showSlot('a', S30, BOX); - renderer.showSlot('b', L30, BOX); - for (const { provider } of globe.layers) - for (let i = 0; i < 40; i += 1) - assert.equal(provider.requestImage(i, 0, 0), undefined); - // Every deferral released its slot (the limit is one), and none asked - // for a frame of its own: one timer holds the single retry. - assert.equal(tileFrames(), 0); - assert.equal(timers.armed(), 1); - assert.equal([...timers.pending.values()][0].ms, TILE_RETRY_DELAY_MS); - timers.flush(); - assert.equal(tileFrames(), 1); - // A throwing provider releases its slot too. - throwNext = true; - assert.throws(() => globe.layers[0].provider.requestImage(0, 0, 0), /boom/); - throwNext = false; - globe.layers[0].provider.requestImage(0, 0, 0); - assert.equal(timers.armed(), 1, 'the next deferral arms a fresh timer'); - renderer.destroy(); - assert.equal(timers.armed(), 0, 'destroy disarms the retry'); - assert.equal(tileFrames(), 1); -}); - -test('against the basemap slot a splits left with no second layer, and leaving the swipe only restyles it', () => { - const { renderer, globe, renders } = fixture(); - renderer.showSlot('a', S30, BOX, { splitDirection: 'left' }); - renderer.hideSlot('b'); - assert.equal(globe.layers.length, 1, 'the basemap is the right side'); - assert.equal(globe.layers[0].splitDirection, -1); - assert.equal(renderer.getOwned().b, null); - const [layer] = globe.layers; - renderer.showSlot('a', S30, BOX, { splitDirection: 'none' }); - assert.deepEqual(globe.layers, [layer], 'no rebuild for a new look'); - assert.equal(layer.splitDirection, 0); - assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']); -}); - -test('replacement imagery uses source-owned tile URLs and credit and tears down normally', () => { - const { renderer, globe } = fixture({ - tileTemplate: (product, day) => - `https://tiles.example/${product}/${day}/{z}/{x}/{y}`, - credit: 'Example imagery', - }); - assert.equal(renderer.showSlot('a', S30, BOX), true); - const layer = globe.layers[0]; - assert.equal( - layer.provider.options.url, - 'https://tiles.example/S30/2026-09-18/{z}/{x}/{y}', - ); - assert.equal(layer.provider.options.credit, 'Example imagery'); - renderer.destroy(); - assert.equal(globe.layers.length, 0); - assert.equal(layer.destroyed, true); -}); diff --git a/src/layers/shared/render/navaraGroundOrder.test.mjs b/src/layers/shared/render/navaraGroundOrder.test.mjs index cf6e1dabe..0bb487977 100644 --- a/src/layers/shared/render/navaraGroundOrder.test.mjs +++ b/src/layers/shared/render/navaraGroundOrder.test.mjs @@ -7,9 +7,10 @@ import { createImageryGround } from '../../recentImagery/render/navaraGround.js' // actual renderer declarations under every activation order, including the // minimal look, whose pipeline has no toneMapping anchor. const declarations = [ - 'gevRecentImagery', + 'gevImagery', 'gevNepalEvent', 'gevObservedWeather', + 'gevGround', 'gevPreSharpenOverlay', ]; // The descriptor a renderer registers; its setup stops there. @@ -39,12 +40,25 @@ async function declared(key, path, className) { .match(new RegExp(`static ${name} = \\[([^\\]]*)\\]`))[1] .matchAll(/'([^']+)'/g), ].map((m) => m[1]); - return { key, after: anchors('insertAfter'), before: anchors('insertBefore') }; + return { + key, + after: anchors('insertAfter'), + before: anchors('insertBefore'), + }; } const specs = [ - registered((sdk) => createImageryGround({}, sdk, {})), + await declared( + 'gevImagery', + '../../../globe/navara/imagery/pass.js', + 'ImageryEffectDesc', + ), registered((sdk) => createImageryGround({}, sdk, {}, 'gevNepalEvent')), await declared('gevObservedWeather', '../../weather/render/navaraSurface.js'), + await declared( + 'gevGround', + '../../../globe/navara/ground/pass.js', + 'GroundEffectDesc', + ), await declared( 'gevPreSharpenOverlay', '../../../globe/navara/overlay.js', diff --git a/src/layers/weather/render/navaraSurface.js b/src/layers/weather/render/navaraSurface.js index fd5bc2779..7e3d71bd0 100644 --- a/src/layers/weather/render/navaraSurface.js +++ b/src/layers/weather/render/navaraSurface.js @@ -95,10 +95,10 @@ function weatherPass(engine, { view, EffectDesc, NavaraEffect }) { empty = null; class WeatherEffect extends EffectDesc { static key = KEY; - static insertAfter = ['gevNepalEvent', 'gevRecentImagery', 'toneMapping']; + static insertAfter = ['gevNepalEvent', 'gevImagery', 'toneMapping']; static insertBefore = [ 'gevEarthquakeGround', - 'gevFirmsGround', + 'gevGround', 'gevPreSharpenOverlay', 'gevSharpen', 'smaa',