From 974a0b6361cc90a6b52d9f5ddc759d781e835b74 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 7 Nov 2026 05:14:42 +0400 Subject: [PATCH] Add ground fills to the globe contract engine.ground draws sets of polygons (with holes), discs, cells and corridors draped on the surface, with fill and outline colours, order and picks, written in bulk and committed. Every engine implements it: - Cesium batches each set into one GroundPrimitive for fills and one GroundPolylinePrimitive for outlines and pixel corridors, classified against terrain and 3D tiles; sets that are not pickable stay out of the pick pass. - Navara draws them in one screen pass after tone mapping that finds each pixel's ground from the scene depth and tests the items in its screen cell; long rings are split into edge bins. Measured at Dubai against SDK vector layers, the pass matched Cesium's frame time up to 10,000 polygons with a few megabytes instead of 70 to 146, drew exact colours and outlines, and hid reliably across an engine switch, where vector layers hidden with update({ show: false }) never hid. Dense corridors remain its limit (10,000 cost 135 ms a frame). - The fake engine keeps the same registry and CPU picks. The contract suite checks it on both engines (Cesium 66/66, Navara 69/69). Earthquakes and fire perimeters have unwired ground prototypes whose drawing matches today's Cesium one (0.998 on Cesium, 0.976 on Navara). docs/GLOBE-PRIMITIVES.md describes the primitives and when to use each. --- docs/CURRENT-STATE.md | 19 +- docs/GLOBE-PRIMITIVES.md | 226 ++++++ scripts/format-scope.json | 19 +- scripts/package-boundaries.json | 877 ++++++------------------ scripts/qa-ground-bench.mjs | 556 +++++++++++++++ scripts/qa-ground-prototypes.mjs | 313 +++++++++ scripts/qa-ground-suite.mjs | 196 ++++++ scripts/qa-ground-switch.mjs | 175 +++++ src/globe/cesium/createCesiumEngine.js | 18 +- src/globe/cesium/ground.js | 294 ++++++++ src/globe/cesium/ground.test.mjs | 159 +++++ src/globe/contract.js | 59 +- src/globe/contract.test.mjs | 13 +- src/globe/ground.js | 509 ++++++++++++++ src/globe/ground.test.mjs | 268 ++++++++ src/globe/navara/createNavaraEngine.js | 26 +- src/globe/navara/ground/index.js | 107 +++ src/globe/navara/ground/index.test.mjs | 89 +++ src/globe/navara/ground/pack.js | 616 +++++++++++++++++ src/globe/navara/ground/pack.test.mjs | 273 ++++++++ src/globe/navara/ground/pass.js | 350 ++++++++++ src/globe/navara/ground/vector.js | 147 ++++ src/globe/navara/index.js | 3 + src/globe/testing/contractSuite.mjs | 484 +++++++++++++ src/globe/testing/fakeEngine.js | 30 +- src/layers/earthquakes/render/ground.js | 69 ++ src/layers/perimeters/render/ground.js | 46 ++ 27 files changed, 5256 insertions(+), 685 deletions(-) create mode 100644 docs/GLOBE-PRIMITIVES.md create mode 100644 scripts/qa-ground-bench.mjs create mode 100644 scripts/qa-ground-prototypes.mjs create mode 100644 scripts/qa-ground-suite.mjs create mode 100644 scripts/qa-ground-switch.mjs create mode 100644 src/globe/cesium/ground.js create mode 100644 src/globe/cesium/ground.test.mjs create mode 100644 src/globe/ground.js create mode 100644 src/globe/ground.test.mjs create mode 100644 src/globe/navara/ground/index.js create mode 100644 src/globe/navara/ground/index.test.mjs create mode 100644 src/globe/navara/ground/pack.js create mode 100644 src/globe/navara/ground/pack.test.mjs create mode 100644 src/globe/navara/ground/pass.js create mode 100644 src/globe/navara/ground/vector.js create mode 100644 src/layers/earthquakes/render/ground.js create mode 100644 src/layers/perimeters/render/ground.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a280d6929..fe2b57075 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -183,10 +183,25 @@ 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 63 of 63 and - Navara 66 of 66. `movers` and `lines` are required of every engine: Cesium + 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 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 + too. They come in sets of polygons with holes, discs, lon/lat cells or + corridors (a width in px or m), draped over terrain and 3D tiles. Each + item has its own fill, outline and `pickId`. A set has a draw `order`, and + shows, hides and replaces its items in bulk. + - Cesium draws them with batched `GroundPrimitive` and + `GroundPolylinePrimitive`. + - Navara draws them in one screen pass classified on the scene depth + (`src/globe/navara/ground/`). + - Navara's vector layers were measured against that pass and lost + (`docs/GLOBE-PRIMITIVES.md`). + - No layer draws through `engine.ground` yet. Earthquakes and fire + 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. - **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 diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md new file mode 100644 index 000000000..9970c4ea3 --- /dev/null +++ b/docs/GLOBE-PRIMITIVES.md @@ -0,0 +1,226 @@ +# 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: + +- `engine.movers`: things at points; +- `engine.lines`: lines in the air; +- `engine.ground`: fills on the ground. + +Each engine implements all three. 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`. + +## Movers: things at a point + +`engine.movers.createSet({ id, kind, capacity, ... })` makes a set of up to +`capacity` instances drawn as one batch. Write them with +`set(index, fields)` and `setCount(n)`, then upload the changes with one +`commit()`. + +There are three kinds: + +- `sprite`: an image from an atlas, a square of `sizePx`, turned by + `rotation`; +- `point`: a filled disc with an outline; +- `model`: a glTF in metres, at least `minimumPixelSize` on screen. + +Use movers for aircraft, vessels, satellites, cameras, stations: anything +that has a position and is drawn the same size on screen (or as a model). + +Instances move every frame without a rebuild. `depthTest` decides whether +the scene hides them. A pick returns the instance's `pickId` when the set is +`pickable`. + +## Lines: polylines in the air + +`engine.lines.createLine({ id, widthPx, color, ... })` makes one polyline. +You can change its positions, colour and model matrix at any time. + +The width is in CSS pixels. The line follows the 3D positions it is given; +it does not follow the ground. Options: + +- `arc: 'geodesic'` bends it along great circles; +- `dash` draws dashes; +- `depthFailAlpha` draws a dimmer copy where the scene hides it. + +Use lines for trails, orbits, launch paths, a ring at a fixed height, and +leader lines. + +## Ground: fills on the ground + +`engine.ground.createSet({ id, kind, order, pickable, drape })` makes a set +of flat fills that drape over the terrain and the 3D tiles. Nothing is +extruded. + +Work with a set like this: + +- `replace(items)` checks and stores all its items at once; +- `commit()` draws them in place of the last ones; +- `show(visible)` shows or hides the whole set; +- `list()` names the sets. + +Positions are `[longitude, latitude]` in degrees, as GeoJSON stores them. +Edges follow great circles. + +There are four kinds: + +| kind | item | for | +| ---------- | ------------------------------------------------------- | --------------------------------------------------------- | +| `polygon` | `rings: [outer, ...holes]`, `fill`, `outline` | perimeters, airspace, footprints, cones, KML polygons | +| `disc` | `center`, `radiusM` (metres along the ground) | earthquakes, air-quality stations, a pad zone | +| `cell` | `west`, `south`, `east`, `north` (meridians, parallels) | GNSS and FIRMS cells, privacy cells | +| `corridor` | `positions`, `color`, `widthPx` or `widthM` | routes, cables, roads, coverage lines, outlines in metres | + +Item options: + +- Every item has its own colours, rgba in 0..1; the alpha is the opacity. +- `fill` and `outline` (`{ color, widthPx }`) are optional; an outline runs + along every ring. +- Within a set, a later item draws on top. Between sets, a higher `order` + draws on top. All ground fills draw under movers, lines and models. +- `pickable: true` makes picks return an item's `pickId`, with `owner` (the + set) and `instance` (the item's index). +- `drape` is `'both'` (the default), `'terrain'` or `'tiles'`. Cesium + honours it; Navara drapes on whatever surface it draws. + +### How each engine draws the ground + +**Cesium** (`src/globe/cesium/ground.js`) builds at most two batches per set: + +- one `GroundPrimitive` for all the fills (polygons, ellipses, rectangles and + metre corridors); +- one `GroundPolylinePrimitive` for all the outlines and pixel corridors. + +Both use per-instance colours and the set's `classificationType`. Cesium +builds them in workers; the set's `ready()` turns true once they are drawn. +A set that is not pickable stays out of the pick pass, so it never hides +what lies under it. + +**Navara** (`src/globe/navara/ground/`) draws every shown set in one screen +pass after tone mapping. Each pixel's ground position comes from the scene +depth (terrain and 3D tiles), and the pixel is tested against the items in +its screen cell: + +- a disc against its radius; +- an area against its edges; +- a corridor against its distance to the line. + +The tests run in each item's own gnomonic plane, so great-circle edges stay +straight. Fills and outlines are anti-aliased across one pixel and blended +in display space, in draw order, as Cesium blends them. + +Large shapes get extra structure: + +- An area with more than 32 edges gets a grid of bins. A pixel then tests + only the edges of its own bin. +- A long corridor is split into chunks of 4 segments. +- The terrain heights under each item bound its screen box. + +Picks are CPU picks at the ground under the pointer (`src/globe/ground.js`). + +Navara's vector layers (`src/globe/navara/ground/vector.js`) were measured +as the other candidate and kept only as a prototype. The measurements are +below. + +### Measurements + +Setup: + +- Dubai, 1280×800, keyless, daylight; +- headless Chrome on an Apple M2 Pro (ANGLE Metal); +- `scripts/qa-ground-bench.mjs`. + +Frame times are medians, from preUpdate to a 1-pixel readPixels after +postRender. "Still" means the camera is at rest; "moving" means it turns +every frame. The empty scene takes about 6–9 ms in both cases. Memory is +measured after a GC, against the scene before the commit. + +Small polygons (10 vertices, 100–400 m, eight colours, 1 px outline): + +| N | Cesium still / moving | Navara pass still / moving | Navara vector still / moving | +| ------ | --------------------- | -------------------------- | ---------------------------- | +| 10 | 4.9 / 5.0 ms | 5.7 / 9.5 ms | 5.7 / 24.5 ms | +| 1 000 | 5.8 / 5.9 ms | 6.2 / 10.9 ms | 5.9 / 29.0 ms | +| 10 000 | 12.0 / 11.2 ms | 10.9 / 15.9 ms | 5.7 / 29.0 ms | + +What each approach costs, apart from frame time: + +| N | Cesium | Navara pass | Navara vector | +| ------ | ---------------------------- | ------------------------- | -------------------------------- | +| 10 | 0.2 s build, +0.2 MB WebGL | 16 ms build, +0.04 MB GPU | 0.24 s build, about +70 MB WebGL | +| 1 000 | 0.23 s build, +19.5 MB WebGL | 68 ms build, +0.45 MB GPU | 0.23 s build, +144 MB WebGL | +| 10 000 | 0.8 s build, +40 MB WebGL | 0.26 s build, +4.0 MB GPU | 0.5 s build, +146 MB WebGL | + +The JS heap at 10 000 polygons: Cesium +47 MB, Navara pass +16 MB, Navara +vector +19 MB. + +One polygon of N vertices across the view (2 px outline), still: + +| N | Cesium | Navara pass | Navara vector | +| ----- | ------ | ----------- | ------------- | +| 100 | 6.2 ms | 6.6 ms | 5.3 ms | +| 1 000 | 5.9 ms | 7.0 ms | 5.3 ms | +| 5 000 | 6.4 ms | 7.3 ms | 6.3 ms | + +Without the bins, the pass took 46 ms at 1 000 vertices and 137 ms at 5 000. + +Corridors (40-point lines, 2.5 px), still / moving: + +| N | Cesium | Navara pass | Navara vector | +| ------ | -------------- | -------------- | ------------- | +| 10 | 5.8 / 5.8 ms | 5.7 / 9.7 ms | 6.6 / 25.8 ms | +| 1 000 | 11.2 / 11.2 ms | 18.6 / 24.4 ms | 6.7 / 28.4 ms | +| 10 000 | 21.6 / 21.6 ms | 135 / 174 ms | 6.3 / 28.0 ms | + +At 10 000 corridors Cesium allocated 613 MB of WebGL memory, the pass 44 MB +and the vector layers about 70 MB. + +Edge quality over the Hajar mountains, oblique view: + +- **Cesium:** a sharp drape with its outline. +- **Navara pass:** the same. +- **Navara vector layers:** they are lit and tone-mapped like the scene, so + a fill's colour is not the one asked for. They draw under Navara's clouds. + A clamped polygon's outline is not drawn at all. + +Show and hide (`scripts/qa-ground-switch.mjs`), before and after a Navara → +Cesium → Navara switch: + +- **The pass:** correct every time. +- **Vector layers hidden by `layer.update({ show: false })`:** never hidden, + before or after a switch. +- **Vector layers hidden by deleting them** (today's workaround in + `navaraPolygons.js`): correct. + +### Decision + +**Navara draws every kind through the screen pass.** For polygons, discs and +cells it is as fast as Cesium up to 10 000 items. It holds a few megabytes +instead of a hundred or more. It draws exact colours and outlines, and its +show and hide are reliable. + +The vector layers draw thin lines cheaply at rest. They cost about 20 ms +more on every frame the camera moves, whatever their count. They draw no +ground outlines and no metre widths. Their colours are lit, and hiding them +needs a rebuild. + +Corridors are the pass's limit: about 1 000 lines draw at 18 ms, but 10 000 +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`. + +### Choosing a primitive + +- **A position and an on-screen size:** a mover. +- **A line at given heights, or one that moves or is restyled every frame** + (trails, orbits, a ring at the terrain height): a line. +- **Anything that lies on the ground** and follows its relief: + - an area: `polygon`, or `disc` or `cell` when that is its shape (they + are exact and cheaper); + - a line on the ground: `corridor`, with `widthM` when its width is a + real width (an outline in metres) and `widthPx` otherwise. +- 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. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5f76e092a..cf0d738b8 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1609,5 +1609,22 @@ "src/layers/transit/render/style.js", "src/layers/transit/render/testAtlas.mjs", "src/testSupport/cctvEngines.mjs", - "src/testSupport/radioTestEngine.mjs" + "src/testSupport/radioTestEngine.mjs", + "src/globe/ground.js", + "src/globe/ground.test.mjs", + "src/globe/cesium/ground.js", + "src/globe/cesium/ground.test.mjs", + "src/globe/navara/ground/index.js", + "src/globe/navara/ground/index.test.mjs", + "src/globe/navara/ground/pack.js", + "src/globe/navara/ground/pack.test.mjs", + "src/globe/navara/ground/pass.js", + "src/globe/navara/ground/vector.js", + "src/layers/earthquakes/render/ground.js", + "src/layers/perimeters/render/ground.js", + "scripts/qa-ground-suite.mjs", + "scripts/qa-ground-bench.mjs", + "scripts/qa-ground-prototypes.mjs", + "scripts/qa-ground-switch.mjs", + "docs/GLOBE-PRIMITIVES.md" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4041e0d56..504b49f86 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -13,14 +13,10 @@ "src/data/infrastructureOverlayEntry.js", "src/sources/infrastructureData.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "application": { - "exports": [ - "./application" - ], + "exports": ["./application"], "modules": [ "src/app/application.js", "src/ui/vueRuntime.js", @@ -161,16 +157,10 @@ "src/ui/i18n.js", "src/ui/surfaceKeyboard.js" ], - "external": [ - "vue", - "pinia", - "vue-i18n" - ] + "external": ["vue", "pinia", "vue-i18n"] }, "viewer": { - "exports": [ - "./application/viewer" - ], + "exports": ["./application/viewer"], "modules": [ "src/app/viewer.js", "src/app/atmosphereCompat.js", @@ -182,16 +172,11 @@ "src/renderGovernor.js", "src/globe/contract.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "vite-build": { "runtime": "node", - "exports": [ - "./build/vite", - "./build/register-vue" - ], + "exports": ["./build/vite", "./build/register-vue"], "modules": [ "build/vite.js", "build/allowedHosts.js", @@ -204,17 +189,11 @@ "build/initialChunkGuard.js", "build/navaraDependencies.js" ], - "external": [ - "vite-plugin-cesium", - "@vitejs/plugin-vue", - "vue" - ] + "external": ["vite-plugin-cesium", "@vitejs/plugin-vue", "vue"] }, "live-providers": { "runtime": "node", - "exports": [ - "./server/providers/live" - ], + "exports": ["./server/providers/live"], "modules": [ "src/sources/adsbFields.js", "server/providers/live.js", @@ -247,25 +226,16 @@ "src/layers/wildlifeSightings/gridViewport.js", "server/providers/inaturalist/queue.js" ], - "external": [ - "ws" - ] + "external": ["ws"] }, "adsb-lol-source": { - "exports": [ - "./sources/adsb-lol" - ], - "modules": [ - "src/data/adsbLolFallback.js", - "src/sources/adsbFields.js" - ], + "exports": ["./sources/adsb-lol"], + "modules": ["src/data/adsbLolFallback.js", "src/sources/adsbFields.js"], "external": [] }, "place-providers": { "runtime": "node", - "exports": [ - "./server/providers/places" - ], + "exports": ["./server/providers/places"], "modules": [ "src/data/distanceFormat.js", "server/providers/places.js", @@ -287,19 +257,13 @@ "external": [] }, "place-payloads": { - "exports": [ - "./sources/places" - ], - "modules": [ - "src/data/placeProviderPayloads.js" - ], + "exports": ["./sources/places"], + "modules": ["src/data/placeProviderPayloads.js"], "external": [] }, "space-providers": { "runtime": "node", - "exports": [ - "./server/providers/space" - ], + "exports": ["./server/providers/space"], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -315,19 +279,13 @@ "external": [] }, "space-requests": { - "exports": [ - "./sources/space" - ], - "modules": [ - "src/data/spaceProviderRequests.js" - ], + "exports": ["./sources/space"], + "modules": ["src/data/spaceProviderRequests.js"], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": [ - "./server/providers/terrain" - ], + "exports": ["./server/providers/terrain"], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js", @@ -339,19 +297,13 @@ "external": [] }, "terrain-source": { - "exports": [ - "./sources/terrain" - ], - "modules": [ - "src/data/terrainHeightsProxy.js" - ], + "exports": ["./sources/terrain"], + "modules": ["src/data/terrainHeightsProxy.js"], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": [ - "./server/providers/traffic" - ], + "exports": ["./server/providers/traffic"], "modules": [ "server/providers/traffic.js", "server/providers/common/http.js", @@ -364,28 +316,18 @@ "external": [] }, "traffic-source": { - "exports": [ - "./sources/traffic" - ], - "modules": [ - "src/data/tomtomTiles.js" - ], + "exports": ["./sources/traffic"], + "modules": ["src/data/tomtomTiles.js"], "external": [] }, "firms-csv": { - "exports": [ - "./sources/firms-csv" - ], - "modules": [ - "src/data/firmsCsv.js" - ], + "exports": ["./sources/firms-csv"], + "modules": ["src/data/firmsCsv.js"], "external": [] }, "firms-provider": { "runtime": "node", - "exports": [ - "./server/providers/firms" - ], + "exports": ["./server/providers/firms"], "modules": [ "server/providers/firms.js", "server/providers/common/http.js", @@ -397,9 +339,7 @@ }, "gbfs-provider": { "runtime": "node", - "exports": [ - "./server/providers/gbfs" - ], + "exports": ["./server/providers/gbfs"], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -412,19 +352,13 @@ "external": [] }, "gbfs-source": { - "exports": [ - "./sources/gbfs" - ], - "modules": [ - "src/data/gbfsSource.js" - ], + "exports": ["./sources/gbfs"], + "modules": ["src/data/gbfsSource.js"], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": [ - "./server/providers/cctv" - ], + "exports": ["./server/providers/cctv"], "modules": [ "server/providers/cctv.js", "server/providers/common/same-site.js", @@ -454,9 +388,7 @@ }, "mapillary-provider": { "runtime": "node", - "exports": [ - "./server/providers/mapillary" - ], + "exports": ["./server/providers/mapillary"], "modules": [ "server/providers/mapillary.js", "server/providers/mapillary/tiles.js", @@ -469,15 +401,11 @@ "src/sources/httpBody.js", "src/i18n/marker.js" ], - "external": [ - "pbf" - ] + "external": ["pbf"] }, "radio-provider": { "runtime": "node", - "exports": [ - "./server/providers/radio" - ], + "exports": ["./server/providers/radio"], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -494,9 +422,7 @@ }, "overpass-provider": { "runtime": "node", - "exports": [ - "./server/providers/overpass" - ], + "exports": ["./server/providers/overpass"], "modules": [ "src/data/distanceFormat.js", "server/providers/common/geo.js", @@ -527,9 +453,7 @@ }, "military-installations-provider": { "runtime": "node", - "exports": [ - "./server/providers/military-installations" - ], + "exports": ["./server/providers/military-installations"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -551,9 +475,7 @@ }, "regional-provider": { "runtime": "node", - "exports": [ - "./server/providers/regional" - ], + "exports": ["./server/providers/regional"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -591,9 +513,7 @@ }, "openai-provider": { "runtime": "node", - "exports": [ - "./server/providers/openai" - ], + "exports": ["./server/providers/openai"], "modules": [ "src/i18n/marker.js", "src/i18n/locale.js", @@ -638,9 +558,7 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": [ - "./server/standalone/key-setup" - ], + "exports": ["./server/standalone/key-setup"], "modules": [ "src/i18n/marker.js", "scripts/pinokio-environment.mjs", @@ -653,9 +571,7 @@ "external": [] }, "place-search": { - "exports": [ - "./search" - ], + "exports": ["./search"], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -686,27 +602,17 @@ "external": [] }, "panel-disclosure": { - "exports": [ - "./ui/panels" - ], - "modules": [ - "src/ui/panelDisclosure.js" - ], + "exports": ["./ui/panels"], + "modules": ["src/ui/panelDisclosure.js"], "external": [] }, "surface-keyboard": { - "exports": [ - "./ui/surfaces" - ], - "modules": [ - "src/ui/surfaceKeyboard.js" - ], + "exports": ["./ui/surfaces"], + "modules": ["src/ui/surfaceKeyboard.js"], "external": [] }, "panel-rail-layout": { - "exports": [ - "./ui/layout" - ], + "exports": ["./ui/layout"], "modules": [ "src/ui/panelRails.js", "src/ui/displayPanelScroll.js", @@ -720,9 +626,7 @@ "external": [] }, "visual-input": { - "exports": [ - "./ui/input" - ], + "exports": ["./ui/input"], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -744,24 +648,15 @@ "src/ui/rail/fieldValue.js", "src/ui/kit/UiSlider.js" ], - "external": [ - "vue" - ] + "external": ["vue"] }, "display-controls": { - "exports": [ - "./ui/display" - ], - "modules": [ - "src/ui/displayControls.js" - ], + "exports": ["./ui/display"], + "modules": ["src/ui/displayControls.js"], "external": [] }, "visual-effects": { - "exports": [ - "./ui/effects", - "./ui/effects/bloom" - ], + "exports": ["./ui/effects", "./ui/effects/bloom"], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -774,14 +669,10 @@ "src/styles/noir.js", "src/i18n/marker.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "map-source-controls": { - "exports": [ - "./ui/maps" - ], + "exports": ["./ui/maps"], "modules": [ "src/i18n/marker.js", "src/ui/mapSource.js", @@ -806,10 +697,7 @@ "external": [] }, "layer-panel": { - "exports": [ - "./ui/layers", - "./ui/layers/feedback" - ], + "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ "src/i18n/marker.js", "src/ui/layers.js", @@ -865,14 +753,10 @@ "src/ui/kit/UiSlider.js", "src/ui/kit/UiSegmented.vue" ], - "external": [ - "vue" - ] + "external": ["vue"] }, "location-controls": { - "exports": [ - "./ui/location" - ], + "exports": ["./ui/location"], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -897,9 +781,7 @@ "external": [] }, "radio-controls": { - "exports": [ - "./ui/radio" - ], + "exports": ["./ui/radio"], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", @@ -924,16 +806,10 @@ "src/i18n/locales/zh-Hant.json", "src/ui/i18n.js" ], - "external": [ - "vue", - "pinia", - "vue-i18n" - ] + "external": ["vue", "pinia", "vue-i18n"] }, "cctv-controls": { - "exports": [ - "./ui/cctv" - ], + "exports": ["./ui/cctv"], "modules": [ "src/data/headingDegrees.js", "src/ui/cctv.js", @@ -962,17 +838,10 @@ "src/ui/stores/status.js", "src/ui/i18n.js" ], - "external": [ - "vue", - "pinia", - "vue-i18n" - ] + "external": ["vue", "pinia", "vue-i18n"] }, "context-controls": { - "exports": [ - "./ui/context", - "./ui/context/policy" - ], + "exports": ["./ui/context", "./ui/context/policy"], "modules": [ "src/i18n/marker.js", "src/ui/context.js", @@ -1055,14 +924,10 @@ "src/geo/", "src/globe/cesium/doubleClick.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "scene-controls": { - "exports": [ - "./ui/scenes" - ], + "exports": ["./ui/scenes"], "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", @@ -1087,16 +952,10 @@ "src/ui/vueRuntime.js", "src/ui/i18n.js" ], - "external": [ - "vue", - "pinia", - "vue-i18n" - ] + "external": ["vue", "pinia", "vue-i18n"] }, "ui-shell": { - "exports": [ - "./ui/shell" - ], + "exports": ["./ui/shell"], "modules": [ "src/data/headingDegrees.js", "src/data/distanceFormat.js", @@ -1339,17 +1198,10 @@ "src/layers/wildlifeSightings/photoText.js", "src/globe/cesium/doubleClick.js" ], - "external": [ - "cesium", - "vue", - "pinia", - "vue-i18n" - ] + "external": ["cesium", "vue", "pinia", "vue-i18n"] }, "ui-styles": { - "exports": [ - "./ui/styles" - ], + "exports": ["./ui/styles"], "modules": [ "src/ui/styles.js", "style.css", @@ -1380,9 +1232,7 @@ "external": [] }, "director": { - "exports": [ - "./director" - ], + "exports": ["./director"], "modules": [ "src/director/playback.js", "src/director/clock.js", @@ -1406,9 +1256,7 @@ "external": [] }, "scenes": { - "exports": [ - "./scenes" - ], + "exports": ["./scenes"], "modules": [ "src/scenes/nepalEvidencePack.js", "src/data/bhoteKoshiIncidentPlaces.js", @@ -1487,17 +1335,10 @@ "src/globe/contract.js", "src/geo/" ], - "external": [ - "cesium", - "vue", - "pinia", - "vue-i18n" - ] + "external": ["cesium", "vue", "pinia", "vue-i18n"] }, "live-observations": { - "exports": [ - "./sources/live" - ], + "exports": ["./sources/live"], "modules": [ "src/sources/adsbFields.js", "src/i18n/marker.js", @@ -1511,9 +1352,7 @@ "external": [] }, "civil-flights": { - "exports": [ - "./layers/flights" - ], + "exports": ["./layers/flights"], "modules": [ "src/data/headingDegrees.js", "src/i18n/marker.js", @@ -1584,14 +1423,10 @@ "src/layers/shared/color.js", "src/layers/aircraft/qaDrawn.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "military-flights": { - "exports": [ - "./layers/military" - ], + "exports": ["./layers/military"], "modules": [ "src/data/headingDegrees.js", "src/i18n/marker.js", @@ -1655,14 +1490,10 @@ "src/globe/cesium/engineFromViewer.js", "src/layers/aircraft/qaDrawn.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "aircraft-mechanics": { - "exports": [ - "./aircraft" - ], + "exports": ["./aircraft"], "modules": [ "src/i18n/marker.js", "src/data/aircraftClass.js", @@ -1695,14 +1526,10 @@ "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "vessel-layer": { - "exports": [ - "./layers/vessels" - ], + "exports": ["./layers/vessels"], "modules": [ "src/data/headingDegrees.js", "src/layers/vessels/cards.js", @@ -1746,14 +1573,10 @@ "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "earthquakes": { - "exports": [ - "./layers/earthquakes" - ], + "exports": ["./layers/earthquakes"], "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", @@ -1765,9 +1588,7 @@ "external": [] }, "recent-imagery": { - "exports": [ - "./layers/recent-imagery" - ], + "exports": ["./layers/recent-imagery"], "modules": [ "src/i18n/marker.js", "src/layers/recentImagery/index.js", @@ -1793,14 +1614,10 @@ "src/ui/unitLabels.js", "src/layers/recentImagery/display.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "fire-layer": { - "exports": [ - "./layers/firms" - ], + "exports": ["./layers/firms"], "modules": [ "src/data/firmsAdapt.js", "src/data/firmsIntensity.js", @@ -1841,9 +1658,7 @@ "external": [] }, "satellites-layer": { - "exports": [ - "./layers/satellites" - ], + "exports": ["./layers/satellites"], "modules": [ "src/data/satelliteClass.js", "src/data/satellitePass.js", @@ -1882,15 +1697,10 @@ "src/time/clock.js", "src/layers/shared/color.js" ], - "external": [ - "cesium", - "satellite.js" - ] + "external": ["cesium", "satellite.js"] }, "launches-layer": { - "exports": [ - "./layers/launches" - ], + "exports": ["./layers/launches"], "modules": [ "src/cyberSonar.js", "src/layers/launches/camera.js", @@ -1932,14 +1742,10 @@ "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "installations-layer": { - "exports": [ - "./layers/installations" - ], + "exports": ["./layers/installations"], "modules": [ "src/data/cameraSensitivity.js", "src/data/installationFeedback.js", @@ -1992,16 +1798,10 @@ "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" ], - "external": [ - "cesium", - "@mapbox/vector-tile", - "pbf" - ] + "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, "awareness-layer": { - "exports": [ - "./layers/awareness" - ], + "exports": ["./layers/awareness"], "modules": [ "src/data/headingDegrees.js", "src/data/distanceFormat.js", @@ -2054,14 +1854,10 @@ "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "traffic-layer": { - "exports": [ - "./layers/traffic" - ], + "exports": ["./layers/traffic"], "modules": [ "src/layers/shared/color.js", "src/data/cameraSensitivity.js", @@ -2120,16 +1916,10 @@ "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" ], - "external": [ - "cesium", - "pbf", - "@mapbox/vector-tile" - ] + "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, "bikeshare-layer": { - "exports": [ - "./layers/bikeshare" - ], + "exports": ["./layers/bikeshare"], "modules": [ "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", @@ -2169,9 +1959,7 @@ "external": [] }, "cctv-layer": { - "exports": [ - "./layers/cctv" - ], + "exports": ["./layers/cctv"], "modules": [ "src/data/headingDegrees.js", "src/data/cctvCards.js", @@ -2225,9 +2013,7 @@ "src/layers/shared/color.js", "src/geo/" ], - "external": [ - "hls.js" - ] + "external": ["hls.js"] }, "street-level-layer": { "exports": [ @@ -2301,17 +2087,10 @@ "src/layers/streetLevel/providers/mapillary/policy.js", "src/geo/" ], - "external": [ - "cesium", - "pbf", - "@mapbox/vector-tile", - "mapillary-js" - ] + "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] }, "alpr-cameras": { - "exports": [ - "./layers/alpr" - ], + "exports": ["./layers/alpr"], "modules": [ "src/data/headingDegrees.js", "src/layers/alpr/index.js", @@ -2344,15 +2123,10 @@ "src/i18n/locales/zh-Hant.json", "src/geo/" ], - "external": [ - "@mapbox/vector-tile", - "pbf" - ] + "external": ["@mapbox/vector-tile", "pbf"] }, "radio-layer": { - "exports": [ - "./layers/radio" - ], + "exports": ["./layers/radio"], "modules": [ "src/data/idSwitches.js", "src/data/firmsIntensity.js", @@ -2401,9 +2175,7 @@ "external": [] }, "submarine-cables": { - "exports": [ - "./layers/submarine-cables" - ], + "exports": ["./layers/submarine-cables"], "modules": [ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", @@ -2432,14 +2204,10 @@ "src/i18n/locales/zh-Hant.json", "src/geo/" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "bundled-cables": { - "exports": [ - "./sources/bundled-cables" - ], + "exports": ["./sources/bundled-cables"], "modules": [ "src/layers/submarineCables/bundledSource.js", "src/data/local_data/telegeography_submarine_cables/cable-geo.json", @@ -2448,10 +2216,7 @@ "external": [] }, "geography": { - "exports": [ - "./geography/natural-earth", - "./geography/neighborhoods" - ], + "exports": ["./geography/natural-earth", "./geography/neighborhoods"], "modules": [ "src/data/naturalEarthRegions.js", "src/geoDistance.js", @@ -2506,9 +2271,7 @@ "src/globe/cesium/credits.js", "src/maps/sceneApply.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "voice-connection": { "exports": [ @@ -2600,11 +2363,7 @@ "src/ui/i18n.js", "src/globe/contract.js" ], - "external": [ - "vue", - "pinia", - "vue-i18n" - ] + "external": ["vue", "pinia", "vue-i18n"] }, "application-components": { "exports": [ @@ -3866,18 +3625,12 @@ ] }, "application-catalog": { - "exports": [ - "./application/catalog" - ], - "modules": [ - "src/app/catalog.js" - ], + "exports": ["./application/catalog"], + "modules": ["src/app/catalog.js"], "external": [] }, "application-layer-construction": { - "exports": [ - "./application/layers" - ], + "exports": ["./application/layers"], "modules": [ "src/data/feedState.js", "src/loadingFeedback.js", @@ -4603,9 +4356,7 @@ ] }, "application-operations": { - "exports": [ - "./application/operations" - ], + "exports": ["./application/operations"], "modules": [ "src/annotations/annotationResolver.js", "src/annotations/resolver.js", @@ -4691,9 +4442,7 @@ ] }, "layer-lifecycle": { - "exports": [ - "./data/lifecycle" - ], + "exports": ["./data/lifecycle"], "modules": [ "src/globe/cesium/engineFromViewer.js", "src/globe/contract.js", @@ -4703,9 +4452,7 @@ "external": [] }, "ui-navigation": { - "exports": [ - "./ui/navigation" - ], + "exports": ["./ui/navigation"], "modules": [ "src/navigationPolicy.js", "src/ui/navigationController.js", @@ -4727,14 +4474,10 @@ "src/globe/cesium/engineFromViewer.js", "src/globe/contract.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ui-share-restoration": { - "exports": [ - "./ui/share-restoration" - ], + "exports": ["./ui/share-restoration"], "modules": [ "src/i18n/marker.js", "src/data/installationFeedback.js", @@ -4766,9 +4509,7 @@ "external": [] }, "ui-visual-settings": { - "exports": [ - "./ui/visual-settings" - ], + "exports": ["./ui/visual-settings"], "modules": [ "src/cyberSonar.js", "src/hudLayouts.js", @@ -4816,17 +4557,10 @@ "src/ui/kit/UiSlider.js", "src/ui/i18n.js" ], - "external": [ - "cesium", - "vue", - "pinia", - "vue-i18n" - ] + "external": ["cesium", "vue", "pinia", "vue-i18n"] }, "ui-panel-chrome": { - "exports": [ - "./ui/panel-chrome" - ], + "exports": ["./ui/panel-chrome"], "modules": [ "src/ui/displayPanelScroll.js", "src/ui/radioPanelPlacement.js", @@ -4861,16 +4595,10 @@ "src/ui/rail/panelState.js", "src/ui/i18n.js" ], - "external": [ - "vue", - "pinia", - "vue-i18n" - ] + "external": ["vue", "pinia", "vue-i18n"] }, "civil-flight-records": { - "exports": [ - "./layers/flights/records" - ], + "exports": ["./layers/flights/records"], "modules": [ "src/i18n/marker.js", "src/layers/flights/records.js", @@ -4884,9 +4612,7 @@ "external": [] }, "civil-flight-ingestion": { - "exports": [ - "./layers/flights/ingestion" - ], + "exports": ["./layers/flights/ingestion"], "modules": [ "src/layers/flights/ingestion.js", "src/layers/flights/recordPolicy.js", @@ -4895,9 +4621,7 @@ "external": [] }, "military-flight-records": { - "exports": [ - "./layers/military/records" - ], + "exports": ["./layers/military/records"], "modules": [ "src/i18n/marker.js", "src/layers/military/records.js", @@ -4910,9 +4634,7 @@ "external": [] }, "military-flight-ingestion": { - "exports": [ - "./layers/military/ingestion" - ], + "exports": ["./layers/military/ingestion"], "modules": [ "src/layers/military/ingestion.js", "src/layers/military/recordPolicy.js" @@ -4920,9 +4642,7 @@ "external": [] }, "vessel-records": { - "exports": [ - "./layers/vessels/records" - ], + "exports": ["./layers/vessels/records"], "modules": [ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js" @@ -4930,9 +4650,7 @@ "external": [] }, "vessel-ingestion": { - "exports": [ - "./layers/vessels/ingestion" - ], + "exports": ["./layers/vessels/ingestion"], "modules": [ "src/layers/vessels/ingestion.js", "src/layers/vessels/viewArea.js", @@ -4941,18 +4659,12 @@ "external": [] }, "directions-source": { - "exports": [ - "./layers/directions/source" - ], - "modules": [ - "src/layers/directions/source.js" - ], + "exports": ["./layers/directions/source"], + "modules": ["src/layers/directions/source.js"], "external": [] }, "recent-imagery-source": { - "exports": [ - "./layers/recent-imagery/source" - ], + "exports": ["./layers/recent-imagery/source"], "modules": [ "src/layers/recentImagery/source.js", "src/layers/recentImagery/catalog.js", @@ -4961,9 +4673,7 @@ "external": [] }, "directions-layer": { - "exports": [ - "./layers/directions" - ], + "exports": ["./layers/directions"], "modules": [ "src/data/distanceFormat.js", "src/i18n/marker.js", @@ -4988,43 +4698,25 @@ "src/ui/unitLabels.js", "src/geo/" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "radio-browser-source": { - "exports": [ - "./sources/radio-browser" - ], - "modules": [ - "src/sources/radioBrowser.js", - "src/data/radioCountry.js" - ], + "exports": ["./sources/radio-browser"], + "modules": ["src/sources/radioBrowser.js", "src/data/radioCountry.js"], "external": [] }, "cctv-types-source": { - "exports": [ - "./sources/cctv-types" - ], - "modules": [ - "src/sources/cctvTypes.js" - ], + "exports": ["./sources/cctv-types"], + "modules": ["src/sources/cctvTypes.js"], "external": [] }, "regional-source": { - "exports": [ - "./sources/regional" - ], - "modules": [ - "src/data/regionalModel.js", - "src/geoDistance.js" - ], + "exports": ["./sources/regional"], + "modules": ["src/data/regionalModel.js", "src/geoDistance.js"], "external": [] }, "voice-action-schemas": { - "exports": [ - "./voice/action-schemas" - ], + "exports": ["./voice/action-schemas"], "modules": [ "src/voice/actionSchemas.js", "src/voice/areaCandidateContract.js", @@ -5034,18 +4726,12 @@ "external": [] }, "voice-session": { - "exports": [ - "./voice/session" - ], - "modules": [ - "src/voice/session.js" - ], + "exports": ["./voice/session"], + "modules": ["src/voice/session.js"], "external": [] }, "voice-session-controls": { - "exports": [ - "./voice/session-controls" - ], + "exports": ["./voice/session-controls"], "modules": [ "src/ui/placeNames.js", "src/voice/sessionCommands.js", @@ -5090,16 +4776,10 @@ "src/ui/stores/pointerReticle.js", "src/ui/i18n.js" ], - "external": [ - "vue", - "pinia", - "vue-i18n" - ] + "external": ["vue", "pinia", "vue-i18n"] }, "alpr-source": { - "exports": [ - "./layers/alpr/source" - ], + "exports": ["./layers/alpr/source"], "modules": [ "src/layers/alpr/policy.js", "src/layers/alpr/records.js", @@ -5111,34 +4791,20 @@ "src/sources/vectorTiles.js", "src/sources/phaseTiming.js" ], - "external": [ - "@mapbox/vector-tile", - "pbf" - ] + "external": ["@mapbox/vector-tile", "pbf"] }, "bikeshare-source": { - "exports": [ - "./layers/bikeshare/source" - ], - "modules": [ - "src/layers/bikeshare/source.js" - ], + "exports": ["./layers/bikeshare/source"], + "modules": ["src/layers/bikeshare/source.js"], "external": [] }, "cctv-source": { - "exports": [ - "./layers/cctv/source" - ], - "modules": [ - "src/layers/cctv/source.js", - "src/layers/cctv/sourcePolicy.js" - ], + "exports": ["./layers/cctv/source"], + "modules": ["src/layers/cctv/source.js", "src/layers/cctv/sourcePolicy.js"], "external": [] }, "earthquakes-source": { - "exports": [ - "./layers/earthquakes/source" - ], + "exports": ["./layers/earthquakes/source"], "modules": [ "src/layers/earthquakes/records.js", "src/layers/earthquakes/source.js" @@ -5146,18 +4812,12 @@ "external": [] }, "firms-source": { - "exports": [ - "./layers/firms/source" - ], - "modules": [ - "src/layers/firms/source.js" - ], + "exports": ["./layers/firms/source"], + "modules": ["src/layers/firms/source.js"], "external": [] }, "installations-source": { - "exports": [ - "./layers/installations/source" - ], + "exports": ["./layers/installations/source"], "modules": [ "src/layers/installations/source.js", "src/data/militaryInstallationData.js", @@ -5176,43 +4836,25 @@ "src/data/bundledJson.js", "src/data/retryableLoad.js" ], - "external": [ - "@mapbox/vector-tile", - "pbf" - ] + "external": ["@mapbox/vector-tile", "pbf"] }, "launches-source": { - "exports": [ - "./layers/launches/source" - ], - "modules": [ - "src/layers/launches/source.js" - ], + "exports": ["./layers/launches/source"], + "modules": ["src/layers/launches/source.js"], "external": [] }, "radio-source": { - "exports": [ - "./layers/radio/source" - ], - "modules": [ - "src/layers/radio/policy.js", - "src/layers/radio/source.js" - ], + "exports": ["./layers/radio/source"], + "modules": ["src/layers/radio/policy.js", "src/layers/radio/source.js"], "external": [] }, "satellites-source": { - "exports": [ - "./layers/satellites/source" - ], - "modules": [ - "src/layers/satellites/source.js" - ], + "exports": ["./layers/satellites/source"], + "modules": ["src/layers/satellites/source.js"], "external": [] }, "traffic-source-factory": { - "exports": [ - "./layers/traffic/source" - ], + "exports": ["./layers/traffic/source"], "modules": [ "src/data/tomtomTiles.js", "src/layers/traffic/flowDecode.js", @@ -5230,16 +4872,10 @@ "src/layers/traffic/roadModes.js", "src/data/flowMatch.js" ], - "external": [ - "@mapbox/vector-tile", - "pbf" - ] + "external": ["@mapbox/vector-tile", "pbf"] }, "reference-sources": { - "exports": [ - "./sources/reference", - "./layers/submarine-cables/source" - ], + "exports": ["./sources/reference", "./layers/submarine-cables/source"], "modules": [ "src/sources/reference.js", "src/layers/earthquakes/source.js", @@ -5266,9 +4902,7 @@ "external": [] }, "nominatim-provider": { - "exports": [ - "./search/nominatim" - ], + "exports": ["./search/nominatim"], "modules": [ "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -5283,28 +4917,17 @@ "external": [] }, "source-http-body": { - "exports": [ - "./sources/http-body" - ], - "modules": [ - "src/sources/httpBody.js", - "src/i18n/marker.js" - ], + "exports": ["./sources/http-body"], + "modules": ["src/sources/httpBody.js", "src/i18n/marker.js"], "external": [] }, "overpass-lexer": { - "exports": [ - "./sources/overpass" - ], - "modules": [ - "src/sources/overpass.js" - ], + "exports": ["./sources/overpass"], + "modules": ["src/sources/overpass.js"], "external": [] }, "nominatim-source": { - "exports": [ - "./sources/nominatim" - ], + "exports": ["./sources/nominatim"], "modules": [ "src/sources/nominatim.js", "src/nominatimGeocode.js", @@ -5318,9 +4941,7 @@ }, "transit-provider": { "runtime": "node", - "exports": [ - "./server/providers/transit" - ], + "exports": ["./server/providers/transit"], "modules": [ "server/providers/common/http.js", "server/providers/common/rate-limit.js", @@ -5338,14 +4959,10 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": [ - "pbf" - ] + "external": ["pbf"] }, "transit-source": { - "exports": [ - "./sources/transit" - ], + "exports": ["./sources/transit"], "modules": [ "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", @@ -5354,14 +4971,10 @@ "src/data/transitProxy.js", "src/sources/transitHistory.js" ], - "external": [ - "pbf" - ] + "external": ["pbf"] }, "transit-standalone": { - "exports": [ - "./layers/transit/standalone" - ], + "exports": ["./layers/transit/standalone"], "modules": [ "src/data/headingDegrees.js", "src/cyberSonar.js", @@ -5474,16 +5087,10 @@ "src/layers/transit/render/style.js", "src/layers/transit/render/display.js" ], - "external": [ - "cesium", - "egm96-universal", - "pbf" - ] + "external": ["cesium", "egm96-universal", "pbf"] }, "transit-layer": { - "exports": [ - "./layers/transit" - ], + "exports": ["./layers/transit"], "modules": [ "src/data/headingDegrees.js", "src/data/inputOwnership.js", @@ -5534,15 +5141,10 @@ "src/geo/", "src/globe/contract.js" ], - "external": [ - "cesium", - "pbf" - ] + "external": ["cesium", "pbf"] }, "transit-source-factory": { - "exports": [ - "./layers/transit/source" - ], + "exports": ["./layers/transit/source"], "modules": [ "src/layers/transit/source.js", "src/sources/transitHistory.js" @@ -5550,9 +5152,7 @@ "external": [] }, "transit-service": { - "exports": [ - "./sources/transit-service" - ], + "exports": ["./sources/transit-service"], "modules": [ "src/data/transitFeeds.js", "src/data/rioRouteNames.js", @@ -5566,14 +5166,10 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": [ - "pbf" - ] + "external": ["pbf"] }, "map-feature-source": { - "exports": [ - "./sources/map-features" - ], + "exports": ["./sources/map-features"], "modules": [ "src/sources/featureGeometry.js", "src/sources/overpassFeaturesRecords.js", @@ -5584,9 +5180,7 @@ "external": [] }, "perimeters": { - "exports": [ - "./layers/perimeters" - ], + "exports": ["./layers/perimeters"], "modules": [ "src/geo/cartesian3.js", "src/geo/math.js", @@ -5613,14 +5207,10 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "gnss": { - "exports": [ - "./layers/gnss" - ], + "exports": ["./layers/gnss"], "modules": [ "src/layers/gnss/index.js", "src/layers/gnss/render/cesium.js", @@ -5644,14 +5234,10 @@ "src/ui/statusText.js", "src/ui/layerNames.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "gnss-source": { - "exports": [ - "./layers/gnss/source" - ], + "exports": ["./layers/gnss/source"], "modules": [ "src/sources/httpBody.js", "src/i18n/marker.js", @@ -5660,9 +5246,7 @@ "external": [] }, "share-view-codec": { - "exports": [ - "./share/view-codec" - ], + "exports": ["./share/view-codec"], "modules": [ "src/share/viewCodec.js", "src/share/follow.js", @@ -5687,18 +5271,12 @@ "external": [] }, "share-link-path": { - "exports": [ - "./share/link-path" - ], - "modules": [ - "src/share/shareLinkPath.js" - ], + "exports": ["./share/link-path"], + "modules": ["src/share/shareLinkPath.js"], "external": [] }, "ghostmaps": { - "exports": [ - "./layers/ghostmaps" - ], + "exports": ["./layers/ghostmaps"], "modules": [ "src/layers/ghostMaps/index.js", "src/layers/ghostMaps/render/cesium.js", @@ -5730,14 +5308,10 @@ "src/layers/streetLevel/tileMath.js", "src/layers/streetLevel/view.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ghostmaps-source": { - "exports": [ - "./layers/ghostmaps/source" - ], + "exports": ["./layers/ghostmaps/source"], "modules": [ "src/layers/ghostMaps/source.js", "src/layers/ghostMaps/catalog.js", @@ -5747,9 +5321,7 @@ "external": [] }, "perimeters-source": { - "exports": [ - "./layers/perimeters/source" - ], + "exports": ["./layers/perimeters/source"], "modules": [ "src/layers/perimeters/records.js", "src/sources/httpBody.js", @@ -5759,9 +5331,7 @@ "external": [] }, "drone-airspace": { - "exports": [ - "./layers/drone-airspace" - ], + "exports": ["./layers/drone-airspace"], "modules": [ "src/layers/droneAirspace/index.js", "src/geo/", @@ -5785,14 +5355,10 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "drone-airspace-source": { - "exports": [ - "./layers/drone-airspace/source" - ], + "exports": ["./layers/drone-airspace/source"], "modules": [ "src/layers/droneAirspace/records.js", "src/sources/httpBody.js", @@ -5802,9 +5368,7 @@ "external": [] }, "tools": { - "exports": [ - "./tools" - ], + "exports": ["./tools"], "modules": [ "src/data/headingDegrees.js", "src/tools/index.js", @@ -5862,14 +5426,10 @@ "src/visualStylePolicy.js", "src/geo/" ], - "external": [ - "satellite.js" - ] + "external": ["satellite.js"] }, "tools-mcp": { - "exports": [ - "./tools/mcp" - ], + "exports": ["./tools/mcp"], "modules": [ "src/tools/mcp/index.js", "src/tools/mcp/protocol.js", @@ -5880,9 +5440,7 @@ "external": [] }, "tools-panel": { - "exports": [ - "./tools/panel" - ], + "exports": ["./tools/panel"], "modules": [ "src/tools/panel.js", "src/tools/globePanel.js", @@ -5891,9 +5449,7 @@ "external": [] }, "tools-services": { - "exports": [ - "./tools/services" - ], + "exports": ["./tools/services"], "modules": [ "src/sources/adsbFields.js", "src/tools/services.js", @@ -5962,15 +5518,10 @@ "src/sources/infrastructureData.js", "src/sources/eventPacks.js" ], - "external": [ - "@mapbox/vector-tile", - "pbf" - ] + "external": ["@mapbox/vector-tile", "pbf"] }, "view": { - "exports": [ - "./view" - ], + "exports": ["./view"], "modules": [ "src/view/index.js", "src/data/idSwitches.js", @@ -5982,9 +5533,7 @@ "external": [] }, "air-quality": { - "exports": [ - "./layers/air-quality" - ], + "exports": ["./layers/air-quality"], "modules": [ "src/geo/cartesian3.js", "src/geo/math.js", @@ -6010,14 +5559,10 @@ "src/ui/statusText.js", "src/ui/layerNames.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "air-quality-source": { - "exports": [ - "./layers/air-quality/source" - ], + "exports": ["./layers/air-quality/source"], "modules": [ "src/layers/airQuality/model.js", "src/layers/airQuality/source.js", @@ -6027,9 +5572,7 @@ "external": [] }, "agent-world-packet": { - "exports": [ - "./agent/world-packet" - ], + "exports": ["./agent/world-packet"], "modules": [ "src/agent/worldPacket.js", "src/data/layerSnapshot.js", @@ -6040,14 +5583,10 @@ "src/i18n/marker.js", "src/geo/" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "wildlife-sightings": { - "exports": [ - "./layers/wildlife-sightings" - ], + "exports": ["./layers/wildlife-sightings"], "modules": [ "src/data/dataCredits.js", "src/data/iconOrientation.js", @@ -6099,14 +5638,10 @@ "src/data/spriteOrder.js", "src/layers/shared/color.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "wildlife-sightings-source": { - "exports": [ - "./layers/wildlife-sightings/source" - ], + "exports": ["./layers/wildlife-sightings/source"], "modules": [ "src/i18n/marker.js", "src/layers/wildlifeSightings/policy.js", diff --git a/scripts/qa-ground-bench.mjs b/scripts/qa-ground-bench.mjs new file mode 100644 index 000000000..daa36ff67 --- /dev/null +++ b/scripts/qa-ground-bench.mjs @@ -0,0 +1,556 @@ +#!/usr/bin/env node +/** + * Measure engine.ground at Dubai (1280×800, keyless) with N polygons: + * Cesium (GroundPrimitive), Navara's screen pass (engine.ground, + * src/globe/navara/ground/pass.js) and the Navara vector-layer prototype + * (src/globe/navara/ground/vector.js). For each N: the time from commit to + * the first item drawn, the frame time with the camera still and turning + * (preUpdate to postRender, closed by a 1-pixel readPixels so the GPU work + * of the frame is in it), the JS heap after a GC and the WebGL bytes the + * page allocated (a tally of bufferData, texImage/texStorage and + * renderbuffer storage, less deletions), each against the empty scene; then + * one polygon over the Hajar mountains, obliquely, for the edge quality. + * + * GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271 + * node scripts/qa-ground-bench.mjs http://localhost:4271 [--counts=10,1000,10000] + * [--paths=cesium,navara-pass,navara-vector] [--out=DIR] + */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; + +const BLOCKED_HOSTS = + /(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/; +const args = process.argv.slice(2); +const origin = args.find((arg) => !arg.startsWith('--')); +const option = (name) => + args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); +if (!origin) { + console.error('Usage: node scripts/qa-ground-bench.mjs [options]'); + process.exit(2); +} +const counts = (option('counts') ?? '10,1000,10000').split(',').map(Number); +// polygons: N small polygons; corridors: N 40-point lines across the view, +// 2.5 px; outline: one polygon of N vertices round the view. +const shape = option('shape') ?? 'polygons'; +const paths = (option('paths') ?? 'cesium,navara-pass,navara-vector').split( + ',', +); +const out = option('out') ?? null; +if (out) fs.mkdirSync(out, { recursive: true }); + +// The page's WebGL allocations, before any script of the app runs. +function installGpuTally() { + const proto = WebGL2RenderingContext.prototype; + const sizes = new Map(); + let total = 0; + const boundBuffers = new WeakMap(); + const boundTextures = new WeakMap(); + const boundRenderbuffers = new WeakMap(); + const set = (key, bytes) => { + total += bytes - (sizes.get(key) ?? 0); + sizes.set(key, bytes); + }; + const drop = (key) => { + total -= sizes.get(key) ?? 0; + sizes.delete(key); + }; + const per = (map, gl) => { + if (!map.has(gl)) map.set(gl, new Map()); + return map.get(gl); + }; + const FLOAT = 0x1406; + const HALF_FLOAT = 0x140b; + const internalBytes = { + 0x8814: 16, // RGBA32F + 0x881a: 8, // RGBA16F + 0x8815: 12, // RGB32F + 0x822e: 4, // R32F + 0x8230: 8, // RG32F + 0x8058: 4, // RGBA8 + 0x8c43: 4, // SRGB8_ALPHA8 + 0x88f0: 4, // DEPTH24_STENCIL8 + 0x8cad: 8, // DEPTH32F_STENCIL8 + 0x8cac: 4, // DEPTH_COMPONENT32F + 0x81a6: 4, // DEPTH_COMPONENT24 + 0x81a5: 2, // DEPTH_COMPONENT16 + 0x8229: 1, // R8 + 0x822b: 2, // RG8 + }; + const texelBytes = (internal, type) => + internalBytes[internal] ?? + (type === FLOAT ? 16 : type === HALF_FLOAT ? 8 : 4); + const wrap = (name, after) => { + const original = proto[name]; + proto[name] = function (...call) { + const result = original.apply(this, call); + try { + after.call(this, call, result); + } catch { + // A tally error never breaks the page. + } + return result; + }; + }; + wrap('bindBuffer', function ([target, buffer]) { + per(boundBuffers, this).set(target, buffer); + }); + wrap('bufferData', function ([target, data]) { + const buffer = per(boundBuffers, this).get(target); + if (buffer) + set(buffer, typeof data === 'number' ? data : (data?.byteLength ?? 0)); + }); + wrap('deleteBuffer', ([buffer]) => drop(buffer)); + wrap('bindTexture', function ([target, texture]) { + per(boundTextures, this).set(target, texture); + }); + wrap('texImage2D', function (call) { + const texture = per(boundTextures, this).get(call[0]); + if (!texture || call[1] !== 0) return; + let bytes; + if (call.length >= 8 && typeof call[3] === 'number') + bytes = call[3] * call[4] * texelBytes(call[2], call[7]); + else { + const source = call[5]; + bytes = (source?.width ?? 0) * (source?.height ?? 0) * 4; + } + set(texture, bytes); + }); + wrap('texStorage2D', function ([target, levels, internal, width, height]) { + const texture = per(boundTextures, this).get(target); + if (texture) + set( + texture, + width * height * texelBytes(internal) * (levels > 1 ? 4 / 3 : 1), + ); + }); + wrap('texImage3D', function (call) { + const texture = per(boundTextures, this).get(call[0]); + if (texture && call[1] === 0) + set(texture, call[3] * call[4] * call[5] * texelBytes(call[2], call[8])); + }); + wrap('texStorage3D', function ([target, , internal, width, height, depth]) { + const texture = per(boundTextures, this).get(target); + if (texture) set(texture, width * height * depth * texelBytes(internal)); + }); + wrap('deleteTexture', ([texture]) => drop(texture)); + wrap('bindRenderbuffer', function ([, renderbuffer]) { + boundRenderbuffers.set(this, renderbuffer); + }); + wrap('renderbufferStorage', function ([, internal, width, height]) { + const renderbuffer = boundRenderbuffers.get(this); + if (renderbuffer) set(renderbuffer, width * height * texelBytes(internal)); + }); + wrap( + 'renderbufferStorageMultisample', + function ([, samples, internal, width, height]) { + const renderbuffer = boundRenderbuffers.get(this); + if (renderbuffer) + set( + renderbuffer, + width * height * texelBytes(internal) * Math.max(1, samples), + ); + }, + ); + wrap('deleteRenderbuffer', ([renderbuffer]) => drop(renderbuffer)); + window.__gpuBytes = () => total; +} + +const DUBAI = { lon: 55.27, lat: 25.15, alt: 70000 }; + +async function openEngine(browser, kind) { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + page.on('pageerror', (error) => console.log(' page error:', error.message)); + await page.evaluateOnNewDocument(installGpuTally); + await page.setRequestInterception(true); + page.on('request', (request) => { + if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort(); + else request.continue(); + }); + await page.goto( + `${origin}/?welcome=0&engine=${kind}#v=2&lat=${DUBAI.lat}&lon=${DUBAI.lon}&alt=${DUBAI.alt}&heading=0&pitch=-90&map=osm&l=`, + ); + await page.waitForFunction( + (expected) => + window.__godsEyeView?.engine?.kind === expected && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 300_000, polling: 250 }, + kind, + ); + // Daylight in Dubai (Navara lights the scene with the app clock's sun). + await page.evaluate(async () => { + const { appClock } = await import('/src/time/clock.js'); + appClock.freeze(Date.UTC(2026, 10, 7, 8, 0)); + window.__godsEyeView.engine.render.request('ground-bench-daylight'); + }); + const cdp = await page.createCDPSession(); + await cdp.send('HeapProfiler.enable'); + // Helpers in the page: polygons, the approach under test, timing. + await page.evaluate(() => { + const { engine } = window.__godsEyeView; + const random = (seed) => () => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 2 ** 32; + }; + const PALETTE = [ + [1, 0.2, 0.2], + [1, 0.6, 0], + [1, 1, 0.2], + [0.2, 0.9, 0.3], + [0, 0.8, 1], + [0.3, 0.4, 1], + [0.7, 0.3, 1], + [0.9, 0.9, 0.9], + ]; + window.__bench = { + engine, + // N polygons over Dubai (deterministic): 10 vertices, 100–400 m; + // item 0 a 3 km magenta polygon at the view centre. + polygons(count) { + const next = random(7); + const items = []; + for (let index = 0; index < count; index++) { + const centre = + index === 0 + ? [55.27, 25.15] + : [54.95 + next() * 0.65, 24.95 + next() * 0.4]; + const radius = index === 0 ? 3000 : 100 + next() * 300; + const ring = []; + for (let k = 0; k < 10; k++) { + const angle = (k / 10) * 2 * Math.PI; + const r = radius * (0.75 + 0.25 * (index === 0 ? 1 : next())); + ring.push([ + centre[0] + + (r * Math.cos(angle)) / + (111320 * Math.cos((centre[1] * Math.PI) / 180)), + centre[1] + (r * Math.sin(angle)) / 110540, + ]); + } + items.push({ + rings: [ring], + // Eight colours, as the layers use a handful (depth bands, + // levels); the vector prototype makes one layer per style. + fill: + index === 0 + ? [1, 0, 1, 0.9] + : [...PALETTE[Math.floor(next() * PALETTE.length)], 0.35], + outline: { color: [1, 1, 1, 0.9], widthPx: 1 }, + pickId: index, + }); + } + return items; + }, + corridors(count) { + const next = random(11); + const items = [window.__bench.centreDisc()]; + for (let index = 1; index < count; index++) { + let lon = 54.95 + next() * 0.65; + let lat = 24.95 + next() * 0.4; + const heading = next() * 2 * Math.PI; + const positions = []; + for (let k = 0; k < 40; k++) { + positions.push([lon, lat]); + const turn = heading + (next() - 0.5) * 1.2; + lon += 0.004 * Math.cos(turn); + lat += 0.004 * Math.sin(turn); + } + items.push({ + positions, + color: [...PALETTE[Math.floor(next() * PALETTE.length)], 0.9], + widthPx: 2.5, + pickId: index, + }); + } + return items; + }, + // The magenta mark of the corridors scenario: a 40 px corridor. + centreDisc: () => ({ + positions: [ + [55.255, 25.15], + [55.285, 25.15], + ], + color: [1, 0, 1, 0.9], + widthPx: 40, + pickId: 0, + }), + outline(count) { + const ring = []; + for (let k = 0; k < count; k++) { + const angle = (k / count) * 2 * Math.PI; + const wobble = 1 + 0.03 * Math.sin(angle * 37); + ring.push([ + 55.27 + 0.28 * wobble * Math.cos(angle), + 25.15 + 0.2 * wobble * Math.sin(angle), + ]); + } + return [ + { + rings: [ring], + fill: [1, 0, 1, 0.5], + outline: { color: [1, 1, 1, 1], widthPx: 2 }, + pickId: 0, + }, + ]; + }, + async ground(kind) { + if (kind !== 'navara-vector') return engine.ground; + const { createNavaraVectorGround } = + await import('/src/globe/navara/ground/vector.js'); + window.__bench.vector ??= createNavaraVectorGround( + engine.extensions.navara.view, + ); + return window.__bench.vector.ground; + }, + frame: () => + new Promise((resolve) => { + const remove = engine.frames.postRender.addEventListener(() => { + remove(); + resolve(); + }); + engine.render.request('ground-bench'); + }), + // Frame times in ms (preUpdate → postRender + 1 px readPixels). + async frameTimes(frames, turn = false) { + const gl = engine.host.canvas.getContext('webgl2'); + const pixel = new Uint8Array(4); + const times = []; + let start = 0; + const removeStart = engine.frames.preUpdate.addEventListener(() => { + start = performance.now(); + }); + const removeEnd = engine.frames.postRender.addEventListener(() => { + gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + times.push(performance.now() - start); + }); + const pose = engine.camera.pose(); + try { + for (let index = 0; index < frames; index++) { + if (turn) + engine.camera.setView({ + destination: pose.position, + orientation: { + heading: pose.heading + (index % 2 ? 0.002 : -0.002), + pitch: pose.pitch, + roll: 0, + }, + }); + await window.__bench.frame(); + } + } finally { + removeStart(); + removeEnd(); + engine.camera.setView({ + destination: pose.position, + orientation: { heading: pose.heading, pitch: pose.pitch, roll: 0 }, + }); + } + times.sort((a, b) => a - b); + const mean = times.reduce((a, b) => a + b, 0) / times.length; + return { + mean, + median: times[Math.floor(times.length / 2)], + p95: times[Math.floor(times.length * 0.95)], + }; + }, + // The centre pixel shows the magenta item. + centreMagenta() { + const canvas = engine.host.canvas; + const c = document.createElement('canvas'); + c.width = 4; + c.height = 4; + const context = c.getContext('2d'); + context.drawImage( + canvas, + canvas.width / 2 - 2, + canvas.height / 2 - 2, + 4, + 4, + 0, + 0, + 4, + 4, + ); + const [r, g, b] = context.getImageData(1, 1, 1, 1).data; + // Lit (the vector layers) or exact (the pass, Cesium). + return r > 150 && b > 150 && g < Math.min(r, b) - 60; + }, + }; + }); + return { page, cdp }; +} + +async function settleTiles(page) { + await page.evaluate(async () => { + const { engine, frame } = window.__bench; + const started = performance.now(); + let loadedFrames = 0; + while (performance.now() - started < 60000 && loadedFrames < 30) { + await frame(); + loadedFrames = engine.surface.tilesLoaded() ? loadedFrames + 1 : 0; + } + }); +} + +async function memory(page, cdp) { + await cdp.send('HeapProfiler.collectGarbage'); + await cdp.send('HeapProfiler.collectGarbage'); + return page.evaluate(() => ({ + js: performance.memory.usedJSHeapSize, + gpu: window.__gpuBytes(), + navara: + window.__bench.engine.extensions?.navara?.view?.memoryStats?.() + ?.gpuBytesEst ?? null, + })); +} + +const mb = (bytes) => (bytes / 2 ** 20).toFixed(2); +const rows = []; + +async function measure(browser, kind) { + const engineKind = kind === 'cesium' ? 'cesium' : 'navara'; + const { page, cdp } = await openEngine(browser, engineKind); + const renderer = await page.evaluate(() => { + const gl = window.__bench.engine.host.canvas.getContext('webgl2'); + const info = gl.getExtension('WEBGL_debug_renderer_info'); + return info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : 'unknown'; + }); + console.log(`${kind}: ${renderer}`); + await settleTiles(page); + // Warm the approach (shader compile) with one item, then measure empty. + await page.evaluate(async (kind) => { + const ground = await window.__bench.ground(kind); + const set = ground.createSet({ id: 'bench-warm', kind: 'polygon' }); + set.replace(window.__bench.polygons(1)); + set.commit(); + const started = performance.now(); + while ( + !window.__bench.centreMagenta() && + performance.now() - started < 30000 + ) + await window.__bench.frame(); + set.destroy(); + for (let index = 0; index < 10; index++) await window.__bench.frame(); + }, kind); + const emptyFrames = { + still: await page.evaluate(() => window.__bench.frameTimes(90)), + turning: await page.evaluate(() => window.__bench.frameTimes(90, true)), + }; + rows.push({ path: kind, shape, count: 0, ...emptyFrames }); + for (const count of counts) { + const before = await memory(page, cdp); + const built = await page.evaluate( + async (kind, count, shape) => { + const ground = await window.__bench.ground(kind); + const items = + shape === 'corridors' + ? window.__bench.corridors(count) + : shape === 'outline' + ? window.__bench.outline(count) + : window.__bench.polygons(count); + const set = ground.createSet({ + id: 'bench', + kind: shape === 'corridors' ? 'corridor' : 'polygon', + }); + const started = performance.now(); + set.replace(items); + set.commit(); + const committed = performance.now() - started; + while ( + !window.__bench.centreMagenta() && + performance.now() - started < 120000 + ) + await window.__bench.frame(); + window.__bench.set = set; + const drawnMs = performance.now() - started; + for (let index = 0; index < 10; index++) await window.__bench.frame(); + return { commitMs: committed, drawnMs }; + }, + kind, + count, + shape, + ); + // Right after the build, before tiles stream again. + const loaded = await memory(page, cdp); + const passBytes = await page.evaluate( + () => window.__bench.engine.groundDiagnostics?.()?.textureBytes ?? null, + ); + await settleTiles(page); + const frames = { + still: await page.evaluate(() => window.__bench.frameTimes(90)), + turning: await page.evaluate(() => window.__bench.frameTimes(90, true)), + }; + if (out) + await page.screenshot({ + path: path.join(out, `dubai-${shape}-${kind}-${count}.png`), + }); + await page.evaluate(async () => { + window.__bench.set.destroy(); + for (let index = 0; index < 20; index++) await window.__bench.frame(); + }); + const released = await memory(page, cdp); + const row = { + path: kind, + shape, + count, + ...built, + ...frames, + jsMB: mb(loaded.js - before.js), + gpuMB: mb(loaded.gpu - before.gpu), + navaraGpuMB: + loaded.navara == null ? null : mb(loaded.navara - before.navara), + passTexturesMB: passBytes == null ? null : mb(passBytes), + jsAfterDestroyMB: mb(released.js - before.js), + gpuAfterDestroyMB: mb(released.gpu - before.gpu), + }; + rows.push(row); + console.log( + ` N=${count}: drawn ${row.drawnMs.toFixed(0)} ms (commit ${row.commitMs.toFixed(0)}), frame still ${frames.still.median.toFixed(2)} ms (empty ${emptyFrames.still.median.toFixed(2)}), turning ${frames.turning.median.toFixed(2)} ms (empty ${emptyFrames.turning.median.toFixed(2)}), JS +${row.jsMB} MB, WebGL +${row.gpuMB} MB${row.navaraGpuMB == null ? '' : `, Navara gpuBytesEst +${row.navaraGpuMB} MB`}${row.passTexturesMB == null || kind !== 'navara-pass' ? '' : `, pass textures ${row.passTexturesMB} MB`}; after destroy JS +${row.jsAfterDestroyMB}, WebGL +${row.gpuAfterDestroyMB} MB`, + ); + } + // Edge quality over terrain: one polygon on the Hajar mountains, oblique. + if (out && shape === 'polygons') { + await page.evaluate(async (kind) => { + const { engine, frame } = window.__bench; + const ground = await window.__bench.ground(kind); + const geo = await import('/src/geo/index.js'); + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(56.05, 24.98, 9000), + orientation: { heading: 0.5, pitch: -0.45, roll: 0 }, + }); + const set = ground.createSet({ id: 'bench-terrain', kind: 'polygon' }); + const ring = []; + for (let k = 0; k < 24; k++) { + const angle = (k / 24) * 2 * Math.PI; + ring.push([ + 56.12 + 0.03 * Math.cos(angle), + 25.07 + 0.022 * Math.sin(angle), + ]); + } + set.replace([ + { + rings: [ring], + fill: [1, 0.3, 0, 0.45], + outline: { color: [1, 1, 0, 1], widthPx: 2 }, + }, + ]); + set.commit(); + for (let index = 0; index < 20; index++) await frame(); + }, kind); + await settleTiles(page); + await page.screenshot({ path: path.join(out, `terrain-${kind}.png`) }); + } + await page.close(); +} + +const browser = await puppeteer.launch({ headless: true }); +try { + for (const kind of paths) await measure(browser, kind); +} finally { + await browser.close(); +} +if (out) + fs.writeFileSync( + path.join(out, `bench-${shape}.json`), + JSON.stringify(rows, null, 2), + ); diff --git a/scripts/qa-ground-prototypes.mjs b/scripts/qa-ground-prototypes.mjs new file mode 100644 index 000000000..97d9acc41 --- /dev/null +++ b/scripts/qa-ground-prototypes.mjs @@ -0,0 +1,313 @@ +#!/usr/bin/env node +/** + * The engine.ground prototypes of earthquakes and fire perimeters + * (src/layers/earthquakes/render/ground.js, + * src/layers/perimeters/render/ground.js) against what each engine draws + * today, on fixed data over California in daylight. Per engine: the canvas + * without the layers, with today's renderers, with the ground prototypes; + * the pixels each drawing changed (a channel off by more than 24), and + * their overlap (intersection over union) with today's Cesium drawing; a + * pick of a quake and of a perimeter through engine.picker. Writes the + * images and a side-by-side to --out. + * + * GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271 + * node scripts/qa-ground-prototypes.mjs http://localhost:4271 --out=DIR + */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; + +const BLOCKED_HOSTS = + /(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/; +const args = process.argv.slice(2); +const origin = args.find((arg) => !arg.startsWith('--')); +const option = (name) => + args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); +const out = option('out') ?? 'qa-ground-prototypes'; +if (!origin) { + console.error( + 'Usage: node scripts/qa-ground-prototypes.mjs --out=DIR', + ); + process.exit(2); +} +fs.mkdirSync(out, { recursive: true }); +const VIEW = { lon: -119.6, lat: 37.2, alt: 700000 }; + +async function capture(page, kind) { + const ui = await page.evaluate( + async (kind, view) => { + const { engine } = window.__godsEyeView; + const frame = () => + new Promise((resolve) => { + const remove = engine.frames.postRender.addEventListener(() => { + remove(); + resolve(); + }); + engine.render.request('ground-prototypes'); + }); + const settle = async () => { + let loaded = 0; + const started = performance.now(); + while (loaded < 20 && performance.now() - started < 60000) { + await frame(); + loaded = engine.surface.tilesLoaded() ? loaded + 1 : 0; + } + }; + const shot = async () => { + await settle(); + const canvas = engine.host.canvas; + const copy = document.createElement('canvas'); + copy.width = canvas.clientWidth; + copy.height = canvas.clientHeight; + copy.getContext('2d').drawImage(canvas, 0, 0, copy.width, copy.height); + return copy.toDataURL('image/png'); + }; + const geo = await import('/src/geo/index.js'); + const { depthColor } = await import('/src/layers/earthquakes/model.js'); + // Fixed quakes and perimeters (one with a hole) in the view. + const quakes = [ + [-121.8, 36.6, 3.1, 8], + [-120.6, 35.9, 4.6, 12], + [-118.4, 37.6, 5.4, 9], + [-117.6, 36.2, 2.8, 120], + [-121.0, 38.4, 3.9, 400], + [-119.0, 38.9, 6.0, 15], + ].map(([lon, lat, mag, depthKm], index) => ({ + stableId: `q${index}`, + usgsId: `q${index}`, + position: geo.cartesian3.fromDegrees(lon, lat), + baseRadius: 2 ** mag * 1000, + color: depthColor(depthKm), + isSignificant: mag >= 5, + fillAlpha: mag >= 5 ? 0.4 : 0.3, + outlineAlpha: mag >= 5 ? 1 : 0.8, + mag, + place: `fixture ${index}`, + time: 0, + depthKm, + })); + const blob = (lon, lat, rx, ry, count = 40) => { + const ring = []; + for (let k = 0; k < count; k++) { + const angle = (k / count) * 2 * Math.PI; + const wobble = 1 + 0.18 * Math.sin(angle * 5); + ring.push([ + lon + rx * wobble * Math.cos(angle), + lat + ry * wobble * Math.sin(angle), + ]); + } + ring.push(ring[0]); + return ring; + }; + const perimeters = [ + { + stableId: 'p0', + containedPct: 10, + polygons: [ + [blob(-120.2, 37.9, 0.35, 0.25), blob(-120.2, 37.9, 0.12, 0.08)], + ], + }, + { + stableId: 'p1', + containedPct: 60, + polygons: [[blob(-118.9, 36.6, 0.25, 0.3)]], + }, + { + stableId: 'p2', + containedPct: 100, + polygons: [ + [blob(-121.4, 37.4, 0.15, 0.12)], + [blob(-121.1, 37.1, 0.1, 0.1)], + ], + }, + ]; + // A restored view settles after the loading screen: take the camera. + await new Promise((resolve) => setTimeout(resolve, 3000)); + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(view.lon, view.lat, view.alt), + }); + const images = { base: await shot() }; + // Today's renderers on this engine. + let quakeRenderer; + let perimeterRenderer; + if (kind === 'cesium') { + const { viewer } = engine.extensions.cesium; + const { createEarthquakesCesiumRenderer } = + await import('/src/layers/earthquakes/render/cesium.js'); + const { createPerimetersCesiumRenderer } = + await import('/src/layers/perimeters/render/cesium.js'); + quakeRenderer = createEarthquakesCesiumRenderer(engine, { viewer }); + perimeterRenderer = createPerimetersCesiumRenderer({ + viewer, + screenSpaceEventHandlerFactory: () => ({ + setInputAction() {}, + destroy() {}, + }), + }); + } else { + const navara = engine.extensions.navara; + const { createEarthquakesNavaraRenderer } = + await import('/src/layers/earthquakes/render/navara.js'); + const { createPerimetersNavaraRenderer } = + await import('/src/layers/perimeters/render/navara.js'); + quakeRenderer = createEarthquakesNavaraRenderer(engine, navara); + perimeterRenderer = createPerimetersNavaraRenderer(engine, navara); + } + quakeRenderer.replace(quakes); + quakeRenderer.setVisible(true); + perimeterRenderer.replace(perimeters); + perimeterRenderer.setVisible(true); + for (let index = 0; index < 60; index++) await frame(); + images.today = await shot(); + quakeRenderer.destroy(); + perimeterRenderer.destroy(); + // The prototypes on engine.ground. + const { createEarthquakesGroundRenderer } = + await import('/src/layers/earthquakes/render/ground.js'); + const { createPerimetersGroundRenderer } = + await import('/src/layers/perimeters/render/ground.js'); + const quakesOnGround = createEarthquakesGroundRenderer(engine); + const perimetersOnGround = createPerimetersGroundRenderer(engine); + quakesOnGround.replace(quakes); + quakesOnGround.setVisible(true); + perimetersOnGround.replace(perimeters); + perimetersOnGround.setVisible(true); + const ready = () => + engine.ground.list().every(({ id }) => id) && + ['earthquakes', 'fire-perimeters'].every((id) => + engine.ground.list().some((entry) => entry.id === id), + ); + while (!ready()) await frame(); + for (let index = 0; index < 60; index++) await frame(); + images.ground = await shot(); + const screen = (lon, lat) => + engine.projector.worldToWindow(geo.cartesian3.fromDegrees(lon, lat)); + const picks = { + quake: engine.picker.pick(screen(-118.4, 37.6))?.id ?? null, + perimeter: engine.picker.pick(screen(-118.9, 36.6))?.id ?? null, + hole: engine.picker.pick(screen(-120.2, 37.9))?.id ?? null, + records: quakesOnGround.records(10).length, + }; + quakesOnGround.destroy(); + perimetersOnGround.destroy(); + return { images, picks }; + }, + kind, + VIEW, + ); + return ui; +} + +async function openEngine(browser, kind) { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + page.on('pageerror', (error) => console.log(' page error:', error.message)); + await page.setRequestInterception(true); + page.on('request', (request) => { + if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort(); + else request.continue(); + }); + await page.goto( + `${origin}/?welcome=0&engine=${kind}#v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch=-90&map=osm&l=`, + ); + await page.waitForFunction( + (expected) => + window.__godsEyeView?.engine?.kind === expected && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 300_000, polling: 250 }, + kind, + ); + // Daylight over California (Navara lights the scene by the app clock). + await page.evaluate(async () => { + const { appClock } = await import('/src/time/clock.js'); + appClock.freeze(Date.UTC(2026, 10, 7, 20, 0)); + }); + return page; +} + +const decode = async (dataUrl) => { + const { data, info } = await sharp( + Buffer.from(dataUrl.split(',')[1], 'base64'), + ) + .raw() + .ensureAlpha() + .toBuffer({ resolveWithObject: true }); + return { data, width: info.width, height: info.height }; +}; +// Pixels where `image` differs from `base` by more than 24 on a channel. +const changed = (base, image) => { + const mask = new Uint8Array(base.width * base.height); + for (let i = 0; i < mask.length; i++) + for (let c = 0; c < 3; c++) + if (Math.abs(base.data[i * 4 + c] - image.data[i * 4 + c]) > 24) { + mask[i] = 1; + break; + } + return mask; +}; +const iou = (a, b) => { + let both = 0; + let either = 0; + for (let i = 0; i < a.length; i++) { + both += a[i] & b[i]; + either += a[i] | b[i]; + } + return either ? both / either : 1; +}; +const count = (mask) => mask.reduce((total, value) => total + value, 0); + +const browser = await puppeteer.launch({ headless: true }); +const results = {}; +try { + for (const kind of ['cesium', 'navara']) { + const page = await openEngine(browser, kind); + results[kind] = await capture(page, kind); + await page.close(); + } +} finally { + await browser.close(); +} +const masks = {}; +for (const kind of ['cesium', 'navara']) { + const { images, picks } = results[kind]; + const base = await decode(images.base); + masks[kind] = { + today: changed(base, await decode(images.today)), + ground: changed(base, await decode(images.ground)), + }; + for (const name of ['base', 'today', 'ground']) + fs.writeFileSync( + path.join(out, `${kind}-${name}.png`), + Buffer.from(images[name].split(',')[1], 'base64'), + ); + console.log( + `${kind}: today changed ${count(masks[kind].today)} px, ground ${count(masks[kind].ground)} px; picks ${JSON.stringify(picks)}`, + ); +} +const reference = masks.cesium.today; +console.log( + `overlap with today's Cesium drawing (IoU of changed pixels): Cesium ground ${iou(reference, masks.cesium.ground).toFixed(3)}, Navara ground ${iou(reference, masks.navara.ground).toFixed(3)}, Navara today ${iou(reference, masks.navara.today).toFixed(3)}`, +); +// Side by side: Cesium today, Cesium ground, Navara today, Navara ground. +const tiles = await Promise.all( + ['cesium-today', 'cesium-ground', 'navara-today', 'navara-ground'].map( + (name) => + sharp(path.join(out, `${name}.png`)) + .resize(640, 400) + .png() + .toBuffer(), + ), +); +await sharp({ + create: { width: 1280, height: 800, channels: 3, background: '#000' }, +}) + .composite( + tiles.map((input, index) => ({ + input, + left: (index % 2) * 640, + top: Math.floor(index / 2) * 400, + })), + ) + .png() + .toFile(path.join(out, 'side-by-side.png')); diff --git a/scripts/qa-ground-suite.mjs b/scripts/qa-ground-suite.mjs new file mode 100644 index 000000000..ded9d646e --- /dev/null +++ b/scripts/qa-ground-suite.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +/** + * Run the contract suite's engine.ground checks in headless Chrome on both + * real engines, with what qa-contract-suite.mjs does not give the suite: a + * readRegion of the engine canvas (the pixel check) besides the real + * pointer (the click pick). Serve the app keyless: + * + * GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271 + * node scripts/qa-ground-suite.mjs http://localhost:4271 [--engine=cesium,navara] [--only=ground:] + * + * Exits 1 when a check fails on any engine. + */ +import puppeteer from 'puppeteer'; + +const BLOCKED_HOSTS = + /(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/; +const args = process.argv.slice(2); +const origin = args.find((arg) => !arg.startsWith('--')); +const option = (name) => + args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); +if (!origin) { + console.error( + 'Usage: node scripts/qa-ground-suite.mjs [--engine=cesium,navara] [--only=prefix]', + ); + process.exit(2); +} +const engines = (option('engine') ?? 'cesium,navara').split(','); +const only = (option('only') ?? 'ground:').split(','); + +async function runOn(browser, kind) { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.setRequestInterception(true); + page.on('request', (request) => { + if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort(); + else request.continue(); + }); + await page.goto( + `${origin}/?welcome=0&engine=${kind}#v=2&lat=10&lon=20&alt=1000000&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 globe drawn and its tiles loaded: a restored view hides the globe + // for a moment after the loading screen goes. + await page.evaluate(async () => { + const { engine } = window.__godsEyeView; + const canvas = engine.host.canvas; + const probe = document.createElement('canvas'); + probe.width = 1; + probe.height = 1; + const context = probe.getContext('2d', { willReadFrequently: true }); + const started = performance.now(); + let settled = 0; + while (performance.now() - started < 120_000 && settled < 20) { + await new Promise((resolve) => { + const remove = engine.frames.postRender.addEventListener(() => { + remove(); + resolve(); + }); + engine.render.request('ground-suite-settle'); + }); + context.drawImage( + canvas, + canvas.width / 2, + canvas.height / 2, + 1, + 1, + 0, + 0, + 1, + 1, + ); + const [r, g, b] = context.getImageData(0, 0, 1, 1).data; + settled = + r + g + b > 60 && engine.surface.tilesLoaded() ? settled + 1 : 0; + } + }); + await page.evaluate(() => + window.__godsEyeView.engine.host.canvas.setAttribute( + 'data-contract-target', + '', + ), + ); + await page.addStyleTag({ + content: + '* { pointer-events: none !important; } [data-contract-target] { pointer-events: auto !important; }', + }); + await page.exposeFunction('__contractPointer', async (type, x, y) => { + const box = await page.evaluate(() => { + const rect = + window.__godsEyeView.engine.host.canvas.getBoundingClientRect(); + return { left: rect.left, top: rect.top }; + }); + const at = [box.left + x, box.top + y]; + if (type === 'click') await page.mouse.click(...at); + else { + await page.mouse.move(at[0] - 1, at[1]); + await page.mouse.move(...at); + } + }); + const results = await page.evaluate(async (only) => { + const { engine } = window.__godsEyeView; + const waitFrames = (count) => + new Promise((resolve) => { + let left = count; + const remove = engine.frames.postRender.addEventListener(() => { + if (--left > 0) return; + remove(); + resolve(); + }); + engine.render.request('ground-suite'); + }); + const pointer = { + click: (x, y) => window.__contractPointer('click', x, y), + move: (x, y) => window.__contractPointer('move', x, y), + }; + // RGBA of the last drawn frame (both engines keep their drawing buffer). + const scratch = document.createElement('canvas'); + const context = scratch.getContext('2d', { willReadFrequently: true }); + const readRegion = (x, y, width, height) => { + const canvas = engine.host.canvas; + const scale = canvas.width / canvas.clientWidth; + scratch.width = width; + scratch.height = height; + context.clearRect(0, 0, width, height); + context.drawImage( + canvas, + x * scale, + y * scale, + width * scale, + height * scale, + 0, + 0, + width, + height, + ); + return context.getImageData(0, 0, width, height).data; + }; + const makeCanvas = (width, height) => + Object.assign(document.createElement('canvas'), { width, height }); + const { createContractSuite } = + await import('/src/globe/testing/contractSuite.mjs'); + const outcome = []; + for (const { name, run } of createContractSuite(engine, { + waitFrames, + pointer, + readRegion, + makeCanvas, + })) { + if (!only.some((prefix) => name.startsWith(prefix))) continue; + try { + await run(); + outcome.push({ name, ok: true }); + } catch (error) { + outcome.push({ + name, + ok: false, + error: String(error?.message ?? error), + }); + } + } + return outcome; + }, only); + await page.close(); + return { engine: kind, results, pageErrors }; +} + +const browser = await puppeteer.launch({ headless: true }); +let failed = false; +try { + for (const kind of engines) { + const { results, pageErrors } = await runOn(browser, kind); + const passed = results.filter((result) => result.ok).length; + console.log(`${kind}: ${passed}/${results.length} checks pass`); + for (const result of results) + if (!result.ok) { + failed = true; + console.log(` FAIL ${result.name}: ${result.error}`); + } else console.log(` ok ${result.name}`); + for (const message of pageErrors) console.log(` page error: ${message}`); + } +} finally { + await browser.close(); +} +process.exit(failed ? 1 : 0); diff --git a/scripts/qa-ground-switch.mjs b/scripts/qa-ground-switch.mjs new file mode 100644 index 000000000..8f2d95ba1 --- /dev/null +++ b/scripts/qa-ground-switch.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +/** + * Show and hide of ground fills on Navara before and after an engine switch + * (Navara → Cesium → Navara, the parked view taken up again): Navara 0.2.1 + * dropped the first `show: false` of a vector layer added right after a + * switch. For engine.ground's screen pass and the vector-layer prototype + * (hidden by layer.update with show false, or by deleting its layers): a + * magenta cell hidden right after its commit must not show, then show, then + * hide again. The pixels are read from the engine canvas. + * + * GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271 + * node scripts/qa-ground-switch.mjs http://localhost:4271 + */ +import puppeteer from 'puppeteer'; +import { pickEngine, waitForEngine } from './qa-strict-harness.mjs'; + +const BLOCKED_HOSTS = + /(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/; +const origin = process.argv[2]; +if (!origin) { + console.error('Usage: node scripts/qa-ground-switch.mjs '); + process.exit(2); +} +const APPROACHES = ['pass', 'vector-update', 'vector-delete']; + +async function trial(page, approach, round) { + return page.evaluate( + async (approach, round) => { + const { engine } = window.__godsEyeView; + const geo = await import('/src/geo/index.js'); + const frame = () => + new Promise((resolve) => { + const remove = engine.frames.postRender.addEventListener(() => { + remove(); + resolve(); + }); + engine.render.request('ground-switch'); + }); + let ground = engine.ground; + let owner = null; + if (approach !== 'pass') { + const { createNavaraVectorGround } = + await import('/src/globe/navara/ground/vector.js'); + owner = createNavaraVectorGround(engine.extensions.navara.view, { + hide: approach === 'vector-update' ? 'update' : 'delete', + }); + ground = owner.ground; + } + const lon = 20 + round * 1.5 + APPROACH_INDEX[approach] * 0.5; + const spot = engine.projector.worldToWindow( + geo.cartesian3.fromDegrees(lon + 0.15, 10), + ); + const magenta = () => { + const canvas = engine.host.canvas; + const copy = document.createElement('canvas'); + copy.width = 16; + copy.height = 16; + const context = copy.getContext('2d'); + const scale = canvas.width / canvas.clientWidth; + context.drawImage( + canvas, + (spot.x - 8) * scale, + (spot.y - 8) * scale, + 16 * scale, + 16 * scale, + 0, + 0, + 16, + 16, + ); + const data = context.getImageData(0, 0, 16, 16).data; + let lit = 0; + for (let i = 0; i < data.length; i += 4) + if ( + data[i] > 150 && + data[i + 2] > 150 && + data[i + 1] < Math.min(data[i], data[i + 2]) - 60 + ) + lit++; + return lit / 256; + }; + // Frames until `want` (shown or not) holds, up to 4 s. + const until = async (want) => { + const started = performance.now(); + while (performance.now() - started < 4000) { + await frame(); + if (magenta() > 0.5 === want) return true; + } + return false; + }; + const set = ground.createSet({ + id: `switch-${approach}-${round}`, + kind: 'cell', + }); + set.replace([ + { + west: lon, + south: 9.85, + east: lon + 0.3, + north: 10.15, + fill: [1, 0, 1, 1], + }, + ]); + set.commit(); + set.show(false); + // Hidden right after the commit: never drawn. + let drawnWhileHidden = false; + for (let index = 0; index < 90; index++) { + await frame(); + if (magenta() > 0.5) drawnWhileHidden = true; + } + set.show(true); + const shows = await until(true); + set.show(false); + const hides = await until(false); + set.destroy(); + owner?.destroy(); + return { hiddenAfterCommit: !drawnWhileHidden, shows, hides }; + }, + approach, + round, + ); +} + +const browser = await puppeteer.launch({ headless: true }); +let failed = false; +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + page.on('pageerror', (error) => console.log(' page error:', error.message)); + await page.setRequestInterception(true); + page.on('request', (request) => { + if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort(); + else request.continue(); + }); + await page.evaluateOnNewDocument((approaches) => { + window.APPROACH_INDEX = Object.fromEntries( + approaches.map((name, index) => [name, index]), + ); + }, APPROACHES); + await page.goto( + `${origin}/?welcome=0&engine=navara#v=2&lat=10&lon=21&alt=900000&heading=0&pitch=-90&map=osm&l=`, + ); + await waitForEngine(page, 'navara', { timeout: 300_000 }); + await page.evaluate(async () => { + const { appClock } = await import('/src/time/clock.js'); + appClock.freeze(Date.UTC(2026, 10, 7, 11, 0)); + }); + const report = (label, result) => { + const ok = result.hiddenAfterCommit && result.shows && result.hides; + if (!ok) failed = true; + console.log( + ` ${label}: hidden right after commit ${result.hiddenAfterCommit ? 'yes' : 'NO'}, shows ${result.shows ? 'yes' : 'NO'}, hides again ${result.hides ? 'yes' : 'NO'}`, + ); + }; + console.log('before a switch:'); + for (const approach of APPROACHES) + report(approach, await trial(page, approach, 0)); + await pickEngine(page, 'cesium'); + await waitForEngine(page, 'cesium', { timeout: 300_000 }); + await pickEngine(page, 'navara'); + await waitForEngine(page, 'navara', { timeout: 300_000 }); + await page.evaluate(async () => { + const geo = await import('/src/geo/index.js'); + window.__godsEyeView.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(21, 10, 900000), + }); + }); + console.log('after Navara → Cesium → Navara:'); + for (const approach of APPROACHES) + report(approach, await trial(page, approach, 1)); +} finally { + await browser.close(); +} +process.exit(failed ? 1 : 0); diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index 5d2a976ae..ca9f07c40 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -5,12 +5,13 @@ // 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. +// set or line; ground sets join scene.groundPrimitives the same way. 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 { createCesiumInput } from './input.js'; import { createCesiumLines } from './lines.js'; import { createCesiumMaps } from './maps.js'; @@ -31,9 +32,10 @@ import { createCesiumSurface } from './surface.js'; * appClock?: import('../../time/clock.js').AppClock, * moverFactories?: object, * lineFactories?: object, + * groundFactories?: object, * }} [options] `requestRender` defaults to the render governor; `appClock` * to the page's app clock; the `create*`/`load*` options and the mover and - * line factories (movers.js, lines.js) are test seams. + * line and ground factories (movers.js, lines.js, ground.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; @@ -54,9 +56,16 @@ export function createCesiumEngine(viewer, options = {}) { requestRender, ...(options.lineFactories ? { factories: options.lineFactories } : {}), }); - // Picks name the mover set or line they hit, with its pickId. + const ground = createCesiumGround(viewer, { + requestRender, + ...(options.groundFactories ? { factories: options.groundFactories } : {}), + }); + // Picks name the mover set, line or ground set they hit, with its pickId. const picker = createCesiumPicker(viewer, { - identify: (picked) => movers.identify(picked) ?? lines.identify(picked), + identify: (picked) => + movers.identify(picked) ?? + lines.identify(picked) ?? + ground.identify(picked), }); const parts = { camera: createCesiumCamera(viewer), @@ -66,6 +75,7 @@ export function createCesiumEngine(viewer, options = {}) { clock: createCesiumClock(viewer, options), movers, lines, + ground, render, // Mover sets in a surface `exclude` are hidden for the sample. surface: createCesiumSurface(viewer, { diff --git a/src/globe/cesium/ground.js b/src/globe/cesium/ground.js new file mode 100644 index 000000000..1909e5971 --- /dev/null +++ b/src/globe/cesium/ground.js @@ -0,0 +1,294 @@ +// engine.ground on Cesium: each set is a PrimitiveCollection placed by +// `order` inside one engine-owned collection in scene.groundPrimitives +// (added with the first set), so a rebuild keeps its place. A commit +// rebuilds the set as at most two batches: one GroundPrimitive of every +// fill (polygons, discs as EllipseGeometry, cells as RectangleGeometry, +// metre corridors as CorridorGeometry) and one GroundPolylinePrimitive of +// every outline and pixel corridor, over the fills, both with per-instance +// colours and the set's classificationType. Cesium builds them in workers; +// the engine keeps requesting frames until they are drawn. A set made +// without pickable stays out of the pick pass (a GroundPrimitive with +// allowPicking false still writes its stencil there and hides what lies +// under it); identify() names a picked item. + +import * as Cesium from 'cesium'; +import { areaRings, createGroundRegistry } from '../ground.js'; + +// Read when a set is made: no module reads Cesium while it loads. +const classificationOf = (drape) => + ({ + both: Cesium.ClassificationType.BOTH, + terrain: Cesium.ClassificationType.TERRAIN, + tiles: Cesium.ClassificationType.CESIUM_3D_TILE, + })[drape]; + +// Outline rings of a disc, in Cesium's own ellipse granularity (1°, at +// least 64 points round). +const DISC_OUTLINE_SEGMENTS = 128; + +// Cesium's GroundPrimitive and GroundPolylinePrimitive, out of the pick pass +// unless asked to pick. +function pickPassAware(Base) { + return class extends Base { + constructor(options) { + super(options); + this.gevPickable = options.allowPicking === true; + } + update(frameState) { + if (frameState.passes.pick && !this.gevPickable) return; + super.update(frameState); + } + }; +} +// Made on first use: no module reads Cesium while it loads. +let groundClasses = null; +const classes = () => + (groundClasses ??= { + fills: pickPassAware(Cesium.GroundPrimitive), + lines: pickPassAware(Cesium.GroundPolylinePrimitive), + }); + +const DEFAULT_FACTORIES = { + fills: (options) => new (classes().fills)(options), + lines: (options) => new (classes().lines)(options), +}; + +const colorOf = (rgba) => new Cesium.Color(rgba[0], rgba[1], rgba[2], rgba[3]); + +function positionsOf(flat) { + const positions = []; + for (let index = 0; index < flat.length; index += 2) + positions.push(Cesium.Cartesian3.fromDegrees(flat[index], flat[index + 1])); + return positions; +} + +const longitude = (degrees) => degrees - 360 * Math.round(degrees / 360); + +/** The fill geometry of one item of a set of `kind` (pure, for tests). */ +export function fillGeometry(kind, item) { + const vertexFormat = Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT; + if (kind === 'disc') + return new Cesium.EllipseGeometry({ + center: Cesium.Cartesian3.fromDegrees(item.center[0], item.center[1]), + semiMajorAxis: item.radiusM, + semiMinorAxis: item.radiusM, + vertexFormat, + }); + if (kind === 'cell') { + const { west, south, east, north } = item.bounds; + return new Cesium.RectangleGeometry({ + rectangle: Cesium.Rectangle.fromDegrees( + longitude(west), + south, + longitude(east), + north, + ), + vertexFormat, + }); + } + if (kind === 'corridor') + return new Cesium.CorridorGeometry({ + positions: positionsOf(item.path), + width: item.widthM, + vertexFormat, + }); + const [outer, ...holes] = item.rings.map(positionsOf); + return new Cesium.PolygonGeometry({ + polygonHierarchy: new Cesium.PolygonHierarchy( + outer, + holes.map((hole) => new Cesium.PolygonHierarchy(hole)), + ), + vertexFormat, + }); +} + +/** + * The geometry instances of a commit: `fills` for the GroundPrimitive, + * `lines` for the GroundPolylinePrimitive. Instance ids name the set and + * item ({ gevGroundSet, instance }). + */ +export function groundInstances(options, items) { + const fills = []; + const lines = []; + items.forEach((item, instance) => { + const id = { gevGroundSet: options.id, instance }; + const attributes = (rgba) => ({ + color: Cesium.ColorGeometryInstanceAttribute.fromColor(colorOf(rgba)), + }); + if (options.kind === 'corridor') { + if (item.widthM) + fills.push( + new Cesium.GeometryInstance({ + geometry: fillGeometry('corridor', item), + id, + attributes: attributes(item.fill), + }), + ); + else + lines.push( + new Cesium.GeometryInstance({ + geometry: new Cesium.GroundPolylineGeometry({ + positions: positionsOf(item.path), + width: item.widthPx, + }), + id, + attributes: attributes(item.fill), + }), + ); + return; + } + if (item.fill) + fills.push( + new Cesium.GeometryInstance({ + geometry: fillGeometry(options.kind, item), + id, + attributes: attributes(item.fill), + }), + ); + if (item.outline) + for (const ring of areaRings(options.kind, item, { + discSegments: DISC_OUTLINE_SEGMENTS, + })) + lines.push( + new Cesium.GeometryInstance({ + geometry: new Cesium.GroundPolylineGeometry({ + positions: positionsOf(ring), + width: item.outline.widthPx, + loop: true, + }), + id, + attributes: attributes(item.outline.color), + }), + ); + }); + return { fills, lines }; +} + +/** + * @param {Cesium.Viewer} viewer + * @param {{ requestRender?: (reason: string) => void, factories?: object }} [options] + * `factories` (the fill and line primitives) is a test seam. + * @returns {{ ground: import('../contract.js').GlobeGround, + * identify: (picked: object) => ({ owner: string, instance: number, id: any, pickable: boolean } | null), + * destroy: () => void }} + */ +export function createCesiumGround( + viewer, + { requestRender = () => viewer.scene.requestRender(), factories = {} } = {}, +) { + const make = { ...DEFAULT_FACTORIES, ...factories }; + const { scene } = viewer; + let root = null; + const ordered = []; + const building = new Set(); + let removeWatch = null; + // Frames until every committed primitive has been built and drawn. + const watch = () => { + if (removeWatch || !scene.postRender) return; + removeWatch = scene.postRender.addEventListener(() => { + for (const primitive of building) + if (primitive.ready || primitive.isDestroyed?.()) + building.delete(primitive); + if (building.size) requestRender('ground-build'); + else { + removeWatch(); + removeWatch = null; + requestRender('ground-ready'); + } + }); + }; + const rootCollection = () => { + if (!root) { + root = new Cesium.PrimitiveCollection({ destroyPrimitives: true }); + scene.groundPrimitives.add(root); + } + return root; + }; + const registry = createGroundRegistry((options) => { + const slot = new Cesium.PrimitiveCollection({ destroyPrimitives: true }); + const at = ordered.filter((entry) => entry.order <= options.order).length; + ordered.splice(at, 0, { order: options.order, slot }); + rootCollection().add(slot, at); + let drawn = []; + const classificationType = classificationOf(options.drape); + const allowPicking = options.pickable; + return { + commit(items) { + for (const primitive of drawn) building.delete(primitive); + slot.removeAll(); + drawn = []; + const { fills, lines } = groundInstances(options, items); + if (fills.length) + drawn.push( + make.fills({ + geometryInstances: fills, + appearance: new Cesium.PerInstanceColorAppearance({ + flat: true, + translucent: true, + }), + classificationType, + allowPicking, + }), + ); + if (lines.length) + drawn.push( + make.lines({ + geometryInstances: lines, + appearance: new Cesium.PolylineColorAppearance(), + classificationType, + allowPicking, + }), + ); + for (const primitive of drawn) { + primitive.gevGroundSet = options.id; + slot.add(primitive); + building.add(primitive); + } + watch(); + requestRender('ground'); + }, + show(visible) { + slot.show = visible; + requestRender('ground'); + }, + ready: () => drawn.every((primitive) => primitive.ready === true), + destroy() { + for (const primitive of drawn) building.delete(primitive); + drawn = []; + ordered.splice( + ordered.findIndex((entry) => entry.slot === slot), + 1, + ); + root.remove(slot); + requestRender('ground'); + }, + }; + }); + return { + ground: registry.ground, + /** The live ground item a Cesium pick names, with its pickId, or null. */ + identify(picked) { + const owner = picked?.id?.gevGroundSet; + const state = owner === undefined ? null : registry.live.get(owner); + if (!state) return null; + const { instance } = picked.id; + const pickId = state.items[instance]?.pickId; + return { + owner, + instance, + id: pickId, + pickable: state.options.pickable && pickId !== undefined, + }; + }, + destroy() { + registry.destroyAll(); + removeWatch?.(); + removeWatch = null; + building.clear(); + if (root) { + scene.groundPrimitives.remove(root); + root = null; + } + }, + }; +} diff --git a/src/globe/cesium/ground.test.mjs b/src/globe/cesium/ground.test.mjs new file mode 100644 index 000000000..86d54e432 --- /dev/null +++ b/src/globe/cesium/ground.test.mjs @@ -0,0 +1,159 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { normalizeGroundItem } from '../ground.js'; +import { createCesiumGround, groundInstances } from './ground.js'; +import { createFakeViewer } from './testing/fakeViewer.mjs'; + +const items = (kind, list) => + list.map((item, index) => normalizeGroundItem(kind, item, index)); + +test('fills batch into GroundPrimitive instances, outlines into ground polylines', () => { + const options = { id: 'zones', kind: 'polygon' }; + const { fills, lines } = groundInstances( + options, + items('polygon', [ + { + rings: [ + [ + [0, 0], + [1, 0], + [1, 1], + ], + [ + [0.6, 0.2], + [0.8, 0.2], + [0.8, 0.4], + ], + ], + fill: [1, 0, 0, 0.5], + outline: { color: [1, 1, 1, 1], widthPx: 2 }, + }, + { + rings: [ + [ + [2, 0], + [3, 0], + [3, 1], + ], + ], + outline: { color: [0, 0, 1, 1], widthPx: 3 }, + }, + ]), + ); + assert.equal(fills.length, 1, 'an item without a fill adds none'); + assert.ok(fills[0].geometry instanceof Cesium.PolygonGeometry); + assert.deepEqual(fills[0].id, { gevGroundSet: 'zones', instance: 0 }); + assert.equal(lines.length, 3, 'one outline per ring'); + assert.ok(lines[0].geometry instanceof Cesium.GroundPolylineGeometry); + assert.equal(lines[0].geometry.loop, true); + assert.equal(lines[0].geometry.width, 2); + assert.deepEqual(lines[2].id, { gevGroundSet: 'zones', instance: 1 }); + const corridors = groundInstances( + { id: 'roads', kind: 'corridor' }, + items('corridor', [ + { + positions: [ + [0, 0], + [1, 0], + ], + color: [1, 1, 1, 1], + widthPx: 4, + }, + { + positions: [ + [0, 1], + [1, 1], + ], + color: [1, 1, 1, 1], + widthM: 30, + }, + ]), + ); + assert.ok(corridors.fills[0].geometry instanceof Cesium.CorridorGeometry); + assert.equal(corridors.lines[0].geometry.width, 4); + const shapes = groundInstances( + { id: 'shapes', kind: 'disc' }, + items('disc', [{ center: [0, 0], radiusM: 500, fill: [1, 1, 1, 1] }]), + ); + assert.ok(shapes.fills[0].geometry instanceof Cesium.EllipseGeometry); +}); + +test('sets join scene.groundPrimitives in order and rebuild on commit', () => { + const viewer = createFakeViewer(); + const added = []; + viewer.scene.groundPrimitives = { + add: (value) => added.push(value), + remove: (value) => added.splice(added.indexOf(value), 1), + }; + const made = []; + const fake = (type) => (options) => { + const primitive = { + type, + options, + ready: false, + destroy() {}, + isDestroyed: () => false, + }; + made.push(primitive); + return primitive; + }; + const renders = []; + const part = createCesiumGround(viewer, { + requestRender: (reason) => renders.push(reason), + factories: { fills: fake('fills'), lines: fake('lines') }, + }); + const high = part.ground.createSet({ id: 'high', kind: 'cell', order: 2 }); + const low = part.ground.createSet({ + id: 'low', + kind: 'cell', + pickable: true, + drape: 'tiles', + }); + assert.equal(added.length, 1, 'one engine collection'); + const root = added[0]; + low.replace([ + { + west: 0, + south: 0, + east: 1, + north: 1, + fill: [1, 0, 0, 1], + outline: { color: [1, 1, 1, 1], widthPx: 2 }, + pickId: 'cell-1', + }, + ]); + low.commit(); + assert.equal(root.length, 2); + assert.equal(root.get(0).get(0), made[0], 'the lower order first'); + assert.deepEqual( + made.map(({ type }) => type), + ['fills', 'lines'], + ); + const [fills] = made; + assert.equal( + fills.options.classificationType, + Cesium.ClassificationType.CESIUM_3D_TILE, + ); + assert.equal(fills.options.allowPicking, true); + assert.equal(low.ready(), false, 'built in workers'); + fills.ready = true; + made[1].ready = true; + assert.equal(low.ready(), true); + assert.deepEqual( + part.identify({ id: { gevGroundSet: 'low', instance: 0 } }), + { owner: 'low', instance: 0, id: 'cell-1', pickable: true }, + ); + high.replace([ + { west: 0, south: 0, east: 1, north: 1, fill: [1, 1, 1, 1], pickId: 'x' }, + ]); + high.commit(); + assert.equal(made.at(-1).options.allowPicking, false); + assert.equal( + part.identify({ id: { gevGroundSet: 'high', instance: 0 } }).pickable, + false, + ); + assert.equal(part.identify({ id: 'entity' }), null); + part.destroy(); + assert.equal(added.length, 0); +}); diff --git a/src/globe/contract.js b/src/globe/contract.js index 5f39b191b..ef673a54c 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -462,6 +462,57 @@ * @property {(options: LineOptions) => Line} createLine * @property {() => { id: string, points: number, shown: boolean }[]} list * + * @typedef {object} GroundSetOptions + * A set of flat fills draped on the ground: on terrain and 3D tiles, + * following their relief, under every mover, line and model (Cesium: + * GroundPrimitive and GroundPolylinePrimitive; src/globe/ground.js checks + * and defaults these). Nothing is extruded. + * @property {string} id Unique among the engine's live ground sets. + * @property {'polygon'|'disc'|'cell'|'corridor'} kind One kind of item per + * set (GroundItem). + * @property {number} [order] Draw order among ground sets (higher draws + * later, on top); 0 by default. Within a set a later item draws on top. + * @property {boolean} [pickable] Whether picks return the set's items (those + * with a pickId); false by default: picks pass through to what lies under. + * @property {'both'|'terrain'|'tiles'} [drape] What the fills drape on: + * terrain and 3D tiles ('both', the default), or only one of them + * (Cesium's classificationType). Navara drapes on whatever surface it + * draws. + * + * @typedef {object} GroundItem + * One fill, by its set's kind. Positions are [longitude, latitude] in + * degrees (GeoJSON order); edges follow great circles; colours are rgba in + * 0..1 (the alpha is the fill's opacity). + * - polygon: `rings` [outer, ...holes], each a list of positions (a + * closing copy of the first is allowed); + * - disc: `center` and `radiusM`, metres along the ground; + * - cell: `west`, `south`, `east`, `north` (west > east across the + * antimeridian), edges along meridians and parallels; + * - corridor: `positions` (a line), `color`, and `widthPx` (CSS pixels) or + * `widthM` (metres). + * Polygons, discs and cells take `fill` (omitted: none) and `outline` + * ({ color, widthPx }, a ground line on the boundary of every ring; + * omitted: none). Every kind takes `pickId`: what a pick of the item + * returns (an item without one is not picked). + * + * @typedef {object} GroundSet + * @property {string} id + * @property {(items: GroundItem[]) => void} replace Checks and stores every + * item at once (the previous items go); drawn after commit(). + * @property {() => void} commit Draws the stored items in place of the last + * ones (one rebuild) and requests a render; picks answer from what was + * committed. + * @property {(visible: boolean) => void} show Shown when created. + * @property {() => boolean} shown + * @property {() => number} count Items committed. + * @property {() => boolean} ready Whether the last commit is drawn (Cesium + * builds ground geometry in workers, a few frames later). + * @property {() => void} destroy + * + * @typedef {object} GlobeGround + * @property {(options: GroundSetOptions) => GroundSet} createSet + * @property {() => { id: string, kind: string, count: number, shown: boolean, order: number }[]} list + * * @typedef {object} GlobeEngine * @property {'cesium'|'navara'} kind * @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host @@ -485,6 +536,7 @@ * @property {GlobeMovers} movers The layers' moving objects (sprites, * points, models), drawn by the engine. * @property {GlobeLines} lines The layers' dynamic polylines. + * @property {GlobeGround} ground The layers' fills on the ground. * @property {() => void} destroy * @property {() => boolean} isDestroyed True once destroy() has run. * @property {{ cesium?: object, navara?: object }} [extensions] @@ -692,6 +744,7 @@ const SHAPE = { clock: { now: isFunction, frameTime: isFunction }, movers: { createSet: isFunction, list: isFunction }, lines: { createLine: isFunction, list: isFunction }, + ground: { createSet: isFunction, list: isFunction }, maps: { setBaseImagery: isFunction, setTerrain: isFunction, @@ -732,7 +785,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']; +const REPORTED_REQUIRED = ['movers', 'lines', 'ground']; const memberAt = (engine, path) => path.split('.').reduce((value, key) => value?.[key], engine); @@ -741,8 +794,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` and `lines` are required; they are - * reported too, true for every valid engine. + * own stages and reports false. `movers`, `lines` and `ground` 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 3e4198e18..4df2107c0 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -310,13 +310,24 @@ test('movers and lines are required and reported as capabilities', () => { const names = createContractSuite(engine, { waitFrames: async () => {}, }).map(({ name }) => name); - for (const prefix of ['movers:', 'lines:', 'colours:']) + for (const prefix of ['movers:', 'lines:', 'colours:', 'ground:']) assert.ok( names.some((name) => name.startsWith(prefix)), prefix, ); }); +test('ground is required and reported as a capability', () => { + const engine = createFakeEngine(); + assert.equal(engineCapabilities(engine).ground, true); + const without = { ...engine }; + delete without.ground; + assert.throws(() => assertGlobeEngine(without), /\bground\b/); + assert.equal(engineCapabilities(without).ground, false); + const half = { ...engine, ground: { createSet: () => {} } }; + assert.throws(() => assertGlobeEngine(half), /ground\.list/); +}); + 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/ground.js b/src/globe/ground.js new file mode 100644 index 000000000..c435921ef --- /dev/null +++ b/src/globe/ground.js @@ -0,0 +1,509 @@ +// engine.ground, the engine-neutral part: sets of flat fills draped on the +// ground (terrain and 3D tiles) — polygons with holes, discs, lon/lat cells +// and corridors (ground-hugging lines of a width in pixels or metres). This +// checks options and items, keeps a set's items as plain arrays the engines +// draw from, and picks them on the CPU for the engines that need it (Navara, +// the fake engine). Ground positions are [longitude, latitude] in degrees, +// as GeoJSON stores them; colours rgba in 0..1. No engine imports. + +import * as geo from '../geo/index.js'; + +export const GROUND_KINDS = Object.freeze([ + 'polygon', + 'disc', + 'cell', + 'corridor', +]); +export const GROUND_DRAPES = Object.freeze(['both', 'terrain', 'tiles']); + +const isFiniteNumber = (value) => + typeof value === 'number' && Number.isFinite(value); +const isObject = (value) => value != null && typeof value === 'object'; +const isList = (value) => Array.isArray(value) || ArrayBuffer.isView(value); + +function fail(message) { + throw new TypeError(`Ground set: ${message}`); +} + +const isColor = (value) => + isList(value) && + value.length === 4 && + Array.prototype.every.call( + value, + (channel) => isFiniteNumber(channel) && channel >= 0 && channel <= 1, + ); + +/** createSet options with defaults, or a TypeError. */ +export function normalizeGroundSetOptions(options) { + if (!isObject(options)) fail('options must be an object'); + const { id, kind } = options; + if (typeof id !== 'string' || !id) fail('id must be a non-empty string'); + if (!GROUND_KINDS.includes(kind)) + fail(`kind must be one of ${GROUND_KINDS.join(', ')}: ${kind}`); + const order = options.order ?? 0; + if (!isFiniteNumber(order)) fail(`order must be a number: ${order}`); + if (options.pickable !== undefined && typeof options.pickable !== 'boolean') + fail('pickable must be a boolean'); + const drape = options.drape ?? 'both'; + if (!GROUND_DRAPES.includes(drape)) + fail(`drape must be one of ${GROUND_DRAPES.join(', ')}: ${drape}`); + return { id, kind, order, pickable: options.pickable === true, drape }; +} + +function lonLat(value, label) { + if ( + !isList(value) || + value.length < 2 || + !isFiniteNumber(value[0]) || + !isFiniteNumber(value[1]) || + value[1] < -90 || + value[1] > 90 + ) + fail(`${label} must be [longitude, latitude] in degrees`); + return value; +} + +// A ring or line as a flat Float64Array [lon, lat, …], each longitude +// within 180° of the one before (a ring across the antimeridian stays in +// one piece); a ring's closing copy of its first point is dropped. +function flatPath(points, label, { ring }) { + if (!Array.isArray(points)) fail(`${label} must be an array of positions`); + const flat = []; + let previous = null; + points.forEach((point, index) => { + const [lon, lat] = lonLat(point, `${label} position ${index}`); + const unwrapped = + previous === null ? lon : lon - 360 * Math.round((lon - previous) / 360); + flat.push(unwrapped, lat); + previous = unwrapped; + }); + const count = flat.length / 2; + if ( + ring && + count > 1 && + Math.abs(flat[0] - flat[(count - 1) * 2]) < 1e-12 && + flat[1] === flat[(count - 1) * 2 + 1] + ) + flat.length -= 2; + const minimum = ring ? 3 : 2; + if (flat.length / 2 < minimum) + fail(`${label} needs at least ${minimum} distinct positions`); + return Float64Array.from(flat); +} + +function fillOf(item) { + if (item.fill == null) return null; + if (!isColor(item.fill)) fail('fill must be [r, g, b, a] in 0..1'); + return Float32Array.from(item.fill); +} + +function outlineOf(item) { + const outline = item.outline; + if (outline == null) return null; + if (!isObject(outline) || !isColor(outline.color)) + fail('outline must be { color: [r, g, b, a], widthPx }'); + if (!(isFiniteNumber(outline.widthPx) && outline.widthPx > 0)) + fail(`outline.widthPx must be a positive number: ${outline.widthPx}`); + return { color: Float32Array.from(outline.color), widthPx: outline.widthPx }; +} + +function boundsOf(paths) { + let west = Infinity; + let east = -Infinity; + let south = Infinity; + let north = -Infinity; + for (const flat of paths) + for (let index = 0; index < flat.length; index += 2) { + west = Math.min(west, flat[index]); + east = Math.max(east, flat[index]); + south = Math.min(south, flat[index + 1]); + north = Math.max(north, flat[index + 1]); + } + return { west, south, east, north }; +} + +const EARTH_RADIUS = geo.ellipsoid.WGS84.maximumRadius; +const DEGREE_M = (Math.PI / 180) * EARTH_RADIUS; + +/** + * One item of a set of `kind`, checked, as the engines draw it: + * { rings: Float64Array[] (polygon and cell, outer first; a cell's corners), + * center, radiusM (disc), path: Float64Array (corridor), fill, outline, + * widthPx | widthM (corridor), pickId, bounds (degrees) }. + */ +export function normalizeGroundItem(kind, item, index = 0) { + if (!isObject(item)) fail(`item ${index} must be an object`); + const base = { pickId: item.pickId }; + if (kind === 'polygon') { + if (!Array.isArray(item.rings) || !item.rings.length) + fail(`item ${index} needs rings: [outer, ...holes]`); + const rings = item.rings.map((ring, at) => + flatPath(ring, `item ${index} ring ${at}`, { ring: true }), + ); + // Holes in the outer ring's longitudes. + for (const hole of rings.slice(1)) { + const shift = 360 * Math.round((hole[0] - rings[0][0]) / 360); + for (let at = 0; at < hole.length; at += 2) hole[at] -= shift; + } + return { + ...base, + rings, + fill: fillOf(item), + outline: outlineOf(item), + bounds: boundsOf(rings.slice(0, 1)), + }; + } + if (kind === 'disc') { + const center = lonLat(item.center, `item ${index} center`); + if (!(isFiniteNumber(item.radiusM) && item.radiusM > 0)) + fail(`item ${index} radiusM must be a positive number`); + const dLat = item.radiusM / DEGREE_M; + const dLon = dLat / Math.max(1e-6, Math.cos(geo.math.toRadians(center[1]))); + return { + ...base, + center: [center[0], center[1]], + radiusM: item.radiusM, + fill: fillOf(item), + outline: outlineOf(item), + bounds: { + west: center[0] - dLon, + east: center[0] + dLon, + south: Math.max(-90, center[1] - dLat), + north: Math.min(90, center[1] + dLat), + }, + }; + } + if (kind === 'cell') { + const { west, south, east, north } = item; + if ( + ![west, south, east, north].every(isFiniteNumber) || + south >= north || + south < -90 || + north > 90 + ) + fail(`item ${index} must be { west, south, east, north } in degrees`); + const eastBound = east <= west ? east + 360 : east; + return { + ...base, + rings: [ + Float64Array.of( + west, + south, + eastBound, + south, + eastBound, + north, + west, + north, + ), + ], + fill: fillOf(item), + outline: outlineOf(item), + bounds: { west, south, east: eastBound, north }, + }; + } + // corridor + const path = flatPath(item.positions, `item ${index} positions`, { + ring: false, + }); + if (!isColor(item.color)) fail(`item ${index} color must be [r, g, b, a]`); + const hasPx = item.widthPx !== undefined; + const hasM = item.widthM !== undefined; + if (hasPx === hasM) fail(`item ${index} needs widthPx or widthM`); + const width = hasPx ? item.widthPx : item.widthM; + if (!(isFiniteNumber(width) && width > 0)) + fail(`item ${index} width must be a positive number`); + return { + ...base, + path, + fill: Float32Array.from(item.color), + outline: null, + ...(hasPx ? { widthPx: width } : { widthM: width }), + bounds: boundsOf([path]), + }; +} + +/** A disc's boundary as a ring of `segments` points (degrees, flat). */ +export function discRing({ center, radiusM }, segments = 64) { + const origin = geo.cartesian3.fromDegrees(center[0], center[1]); + const up = geo.ellipsoid.WGS84.geodeticSurfaceNormal( + origin, + geo.cartesian3.create(), + ); + const east = geo.cartesian3.normalize( + geo.cartesian3.create(-origin.y, origin.x, 0), + geo.cartesian3.create(), + ); + if (!Number.isFinite(east.x)) Object.assign(east, { x: 1, y: 0, z: 0 }); + const north = geo.cartesian3.cross(up, east, geo.cartesian3.create()); + const angle = radiusM / geo.cartesian3.magnitude(origin); + const flat = new Float64Array(segments * 2); + const point = geo.cartesian3.create(); + let previous = null; + for (let index = 0; index < segments; index++) { + const theta = (index * 2 * Math.PI) / segments; + const along = Math.sin(angle) * geo.cartesian3.magnitude(origin); + const a = Math.sin(theta) * along; + const b = Math.cos(theta) * along; + point.x = origin.x * Math.cos(angle) + east.x * a + north.x * b; + point.y = origin.y * Math.cos(angle) + east.y * a + north.y * b; + point.z = origin.z * Math.cos(angle) + east.z * a + north.z * b; + const place = geo.cartographic.fromCartesian(point); + let lon = geo.math.toDegrees(place.longitude); + if (previous !== null) lon -= 360 * Math.round((lon - previous) / 360); + previous = lon; + flat[index * 2] = lon; + flat[index * 2 + 1] = geo.math.toDegrees(place.latitude); + } + return flat; +} + +/** + * A cell's boundary with its parallels split into steps of at most + * `stepDeg` degrees (meridians are great circles and stay two points). + */ +export function cellRing({ bounds }, stepDeg = 0.25) { + const { west, south, east, north } = bounds; + const steps = Math.max(1, Math.ceil((east - west) / stepDeg)); + const flat = []; + for (let index = 0; index <= steps; index++) + flat.push(west + ((east - west) * index) / steps, south); + for (let index = steps; index >= 0; index--) + flat.push(west + ((east - west) * index) / steps, north); + return Float64Array.from(flat); +} + +/** The rings an item's area draws: a polygon's, a disc's or a cell's. */ +export function areaRings(kind, item, { discSegments = 64, cellStepDeg } = {}) { + if (kind === 'disc') return [discRing(item, discSegments)]; + if (kind === 'cell') return [cellRing(item, cellStepDeg)]; + return item.rings; +} + +/** + * engine.ground over an engine's drawing: `build(options)` returns the + * engine part of one set ({ commit(items), show(visible), destroy(), + * ready?() }), called with checked items. Set ids are unique while the set + * lives; destroyAll() is for the engine's destroy. + */ +export function createGroundRegistry(build) { + const live = new Map(); + const ground = { + createSet(input) { + const options = normalizeGroundSetOptions(input); + if (live.has(options.id)) + throw new Error(`Ground set ${options.id} already exists`); + const part = build(options); + const state = { + options, + part, + pending: [], + // What the last commit drew: picks read it. + items: [], + shown: true, + destroyed: false, + }; + const alive = () => { + if (state.destroyed) throw new Error('Ground set was destroyed'); + }; + const set = { + id: options.id, + replace(items) { + alive(); + if (!Array.isArray(items)) fail('items must be an array'); + state.pending = items.map((item, index) => + normalizeGroundItem(options.kind, item, index), + ); + }, + commit() { + alive(); + state.items = state.pending; + part.commit(state.items); + }, + show(visible) { + alive(); + state.shown = Boolean(visible); + part.show(state.shown); + }, + shown: () => state.shown, + count: () => state.items.length, + ready: () => + !state.destroyed && (part.ready ? part.ready() === true : true), + destroy() { + if (state.destroyed) return; + state.destroyed = true; + live.delete(options.id); + part.destroy(); + state.pending = []; + state.items = []; + }, + isDestroyed: () => state.destroyed, + }; + state.set = set; + live.set(options.id, state); + return set; + }, + list: () => + [...live.values()].map(({ options, items, shown }) => ({ + id: options.id, + kind: options.kind, + count: items.length, + shown, + order: options.order, + })), + }; + return { + ground, + live, + destroyAll() { + for (const state of [...live.values()]) state.set.destroy(); + }, + }; +} + +// Even-odd point in a flat [lon, lat, …] ring, in local x/y. +function inRing(flat, x, y, toLocal) { + let inside = false; + const count = flat.length / 2; + let [ax, ay] = toLocal(flat[(count - 1) * 2], flat[(count - 1) * 2 + 1]); + for (let index = 0; index < count; index++) { + const [bx, by] = toLocal(flat[index * 2], flat[index * 2 + 1]); + if (ay > y !== by > y && x < ((bx - ax) * (y - ay)) / (by - ay) + ax) + inside = !inside; + ax = bx; + ay = by; + } + return inside; +} + +function pathDistance(flat, closed, x, y, toLocal) { + const count = flat.length / 2; + let best = Infinity; + let [ax, ay] = closed + ? toLocal(flat[(count - 1) * 2], flat[(count - 1) * 2 + 1]) + : toLocal(flat[0], flat[1]); + for (let index = closed ? 0 : 1; index < count; index++) { + const [bx, by] = toLocal(flat[index * 2], flat[index * 2 + 1]); + const dx = bx - ax; + const dy = by - ay; + const lengthSquared = dx * dx + dy * dy; + const t = + lengthSquared > 0 + ? Math.max( + 0, + Math.min(1, ((x - ax) * dx + (y - ay) * dy) / lengthSquared), + ) + : 0; + best = Math.min(best, Math.hypot(x - (ax + t * dx), y - (ay + t * dy))); + ax = bx; + ay = by; + } + return best; +} + +/** Outlines and px corridors are picked at least this far out, in pixels. */ +const GROUND_PICK_PX = 3; + +/** + * CPU picks of ground sets: the ground under a window position + * (`pickGlobe`), tested against the shown, pickable sets' committed items + * (a fill inside its rings, minus holes; an outline or corridor within half + * its width + 3 px). Hits rank under every mover and line (onTop −1), then + * by set order (higher on top), then the later item of a set on top. + * @param {{ pickGlobe: (at: object) => (object | undefined), + * camera: { livePosition: object, pose: () => { fovy: number } }, + * size: () => { width: number, height: number }, + * sets: () => { options: object, items: object[], shown: boolean }[] }} ports + * @returns {(at: { x: number, y: number }) => { onTop: number, order: number, depth: number, result: object }[]} + */ +export function createGroundPicks({ pickGlobe, camera, size, sets }) { + return (at) => { + const candidates = sets().filter( + (state) => state.shown && state.options.pickable && state.items.length, + ); + if (!candidates.length) return []; + const world = pickGlobe(at); + if (!world) return []; + const place = geo.cartographic.fromCartesian(world); + if (!place) return []; + const lon0 = geo.math.toDegrees(place.longitude); + const lat0 = geo.math.toDegrees(place.latitude); + const cosLat = Math.cos(place.latitude); + // Equirectangular metres around the picked point. + const toLocal = (lon, lat) => [ + (lon - lon0 - 360 * Math.round((lon - lon0) / 360)) * DEGREE_M * cosLat, + (lat - lat0) * DEGREE_M, + ]; + const depth = geo.cartesian3.distance(world, camera.livePosition); + const metresPerPixel = + (2 * Math.tan(camera.pose().fovy / 2) * depth) / + Math.max(1, size().height); + const hits = []; + for (const { options, items } of candidates) { + const marginDeg = + (64 * metresPerPixel) / DEGREE_M / Math.max(0.01, cosLat); + for (let index = items.length - 1; index >= 0; index--) { + const item = items[index]; + if (item.pickId === undefined) continue; + const { bounds } = item; + const lon = + lon0 - + 360 * Math.round((lon0 - (bounds.west + bounds.east) / 2) / 360); + const extra = + options.kind === 'corridor' && item.widthM + ? item.widthM / DEGREE_M / Math.max(0.01, cosLat) + : 0; + if ( + lat0 < bounds.south - marginDeg - extra || + lat0 > bounds.north + marginDeg + extra || + lon < bounds.west - marginDeg - extra || + lon > bounds.east + marginDeg + extra + ) + continue; + let hit = false; + if (options.kind === 'corridor') { + const distance = pathDistance(item.path, false, 0, 0, toLocal); + const half = item.widthM + ? item.widthM / 2 + GROUND_PICK_PX * metresPerPixel + : (item.widthPx / 2 + GROUND_PICK_PX) * metresPerPixel; + hit = distance <= half; + } else if (options.kind === 'disc') { + const distance = geo.cartesian3.distance( + world, + geo.cartesian3.fromDegrees( + item.center[0], + item.center[1], + place.height, + ), + ); + hit = + (item.fill && distance <= item.radiusM) || + (item.outline && + Math.abs(distance - item.radiusM) <= + (item.outline.widthPx / 2 + GROUND_PICK_PX) * metresPerPixel); + } else { + const rings = + options.kind === 'cell' ? [cellRing(item, 1)] : item.rings; + if (item.fill) + hit = + inRing(rings[0], 0, 0, toLocal) && + !rings.slice(1).some((ring) => inRing(ring, 0, 0, toLocal)); + if (!hit && item.outline) + hit = rings.some( + (ring) => + pathDistance(ring, true, 0, 0, toLocal) <= + (item.outline.widthPx / 2 + GROUND_PICK_PX) * metresPerPixel, + ); + } + if (hit) + hits.push({ + onTop: -1, + order: options.order, + depth, + result: { id: item.pickId, owner: options.id, instance: index }, + }); + } + } + return hits; + }; +} diff --git a/src/globe/ground.test.mjs b/src/globe/ground.test.mjs new file mode 100644 index 000000000..d3dcc26b2 --- /dev/null +++ b/src/globe/ground.test.mjs @@ -0,0 +1,268 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../geo/index.js'; +import { + cellRing, + createGroundPicks, + createGroundRegistry, + discRing, + normalizeGroundItem, + normalizeGroundSetOptions, +} from './ground.js'; + +test('set options are checked and defaulted', () => { + assert.deepEqual(normalizeGroundSetOptions({ id: 'a', kind: 'disc' }), { + id: 'a', + kind: 'disc', + order: 0, + pickable: false, + drape: 'both', + }); + for (const bad of [ + null, + { kind: 'disc' }, + { id: 'a', kind: 'line' }, + { id: 'a', kind: 'disc', order: Infinity }, + { id: 'a', kind: 'disc', pickable: 1 }, + { id: 'a', kind: 'disc', drape: 'water' }, + ]) + assert.throws(() => normalizeGroundSetOptions(bad), TypeError); +}); + +test('polygon rings drop their closing copy and stay in one piece across the antimeridian', () => { + const item = normalizeGroundItem('polygon', { + rings: [ + [ + [179, 0], + [-179, 0], + [-179, 1], + [179, 1], + [179, 0], + ], + [ + [-179.8, 0.2], + [179.8, 0.2], + [179.8, 0.8], + ], + ], + fill: [1, 0, 0, 0.5], + pickId: 'p', + }); + assert.deepEqual([...item.rings[0]], [179, 0, 181, 0, 181, 1, 179, 1]); + // The hole in the outer ring's longitudes. + assert.deepEqual([...item.rings[1]], [180.2, 0.2, 179.8, 0.2, 179.8, 0.8]); + assert.deepEqual(item.bounds, { west: 179, south: 0, east: 181, north: 1 }); + assert.equal(item.pickId, 'p'); + assert.equal(item.outline, null); +}); + +test('items of every kind are checked', () => { + const disc = normalizeGroundItem('disc', { + center: [20, 60], + radiusM: 1000, + outline: { color: [1, 1, 1, 1], widthPx: 2 }, + }); + assert.equal(disc.fill, null); + assert.equal(disc.outline.widthPx, 2); + assert.ok( + disc.bounds.east - disc.bounds.west > + 2 * (disc.bounds.north - disc.bounds.south) * 0.99, + ); + const cell = normalizeGroundItem('cell', { + west: 179, + south: 0, + east: -179, + north: 1, + }); + assert.equal(cell.bounds.east, 181); + const corridor = normalizeGroundItem('corridor', { + positions: [ + [0, 0], + [1, 0], + ], + color: [1, 1, 1, 1], + widthM: 30, + }); + assert.equal(corridor.widthM, 30); + assert.equal(corridor.widthPx, undefined); + for (const [kind, bad] of [ + ['polygon', { rings: [] }], + [ + 'polygon', + { + rings: [ + [ + [0, 0], + [1, 0], + [0, 0], + ], + ], + }, + ], + [ + 'polygon', + { + rings: [ + [ + [0, 0], + [1, 0], + [1, 95], + ], + ], + }, + ], + ['disc', { center: [0, 0], radiusM: 0 }], + ['disc', { center: [0, 0], radiusM: 1, outline: { color: [1, 1, 1, 1] } }], + ['cell', { west: 0, south: 1, east: 1, north: 0 }], + ['corridor', { positions: [[0, 0]], color: [1, 1, 1, 1], widthPx: 2 }], + [ + 'corridor', + { + positions: [ + [0, 0], + [1, 1], + ], + color: [1, 1, 1, 1], + }, + ], + [ + 'corridor', + { + positions: [ + [0, 0], + [1, 1], + ], + color: [1, 1, 1, 1], + widthPx: 1, + widthM: 1, + }, + ], + ]) + assert.throws(() => normalizeGroundItem(kind, bad), TypeError, kind); +}); + +test('a disc ring lies at its radius; a cell ring follows its parallels', () => { + const ring = discRing({ center: [55, 25], radiusM: 10_000 }, 32); + assert.equal(ring.length, 64); + const centre = geo.cartesian3.fromDegrees(55, 25); + for (let index = 0; index < ring.length; index += 2) { + const distance = geo.cartesian3.distance( + centre, + geo.cartesian3.fromDegrees(ring[index], ring[index + 1]), + ); + assert.ok(Math.abs(distance - 10_000) < 15, `${distance}`); + } + const cell = normalizeGroundItem('cell', { + west: 0, + south: 10, + east: 1, + north: 11, + }); + const flat = cellRing(cell, 0.25); + assert.equal(flat.length / 2, 10); + assert.deepEqual([...flat.slice(0, 4)], [0, 10, 0.25, 10]); +}); + +test('the registry stores on replace, draws on commit, lists, shows and destroys', () => { + const calls = []; + const { ground, destroyAll } = createGroundRegistry((options) => ({ + commit: (items) => calls.push(['commit', options.id, items.length]), + show: (visible) => calls.push(['show', options.id, visible]), + destroy: () => calls.push(['destroy', options.id]), + })); + const set = ground.createSet({ id: 'zones', kind: 'cell', order: 2 }); + assert.throws( + () => ground.createSet({ id: 'zones', kind: 'cell' }), + /exists/, + ); + set.replace([{ west: 0, south: 0, east: 1, north: 1, fill: [1, 0, 0, 1] }]); + assert.equal(set.count(), 0); + assert.throws(() => set.replace([{ west: 0 }]), TypeError); + set.commit(); + assert.equal(set.count(), 1); + set.show(false); + assert.deepEqual(ground.list(), [ + { id: 'zones', kind: 'cell', count: 1, shown: false, order: 2 }, + ]); + assert.equal(set.ready(), true); + destroyAll(); + assert.deepEqual(ground.list(), []); + assert.equal(set.ready(), false); + assert.throws(() => set.commit(), /destroyed/); + assert.deepEqual(calls, [ + ['commit', 'zones', 1], + ['show', 'zones', false], + ['destroy', 'zones'], + ]); +}); + +test('CPU picks find the item under the picked ground, on top first', () => { + const states = []; + const { ground, live } = createGroundRegistry(() => ({ + commit() {}, + show() {}, + destroy() {}, + })); + const low = ground.createSet({ id: 'low', kind: 'cell', pickable: true }); + low.replace([ + { west: 0, south: 0, east: 2, north: 2, fill: [1, 0, 0, 1], pickId: 'a' }, + { + west: 0.5, + south: 0.5, + east: 1.5, + north: 1.5, + fill: [1, 0, 0, 1], + pickId: 'b', + }, + ]); + low.commit(); + const corridor = ground.createSet({ + id: 'roads', + kind: 'corridor', + pickable: true, + order: 1, + }); + corridor.replace([ + { + positions: [ + [0, 3], + [2, 3], + ], + color: [1, 1, 1, 1], + widthM: 2000, + pickId: 'road', + }, + ]); + corridor.commit(); + const quiet = ground.createSet({ id: 'quiet', kind: 'disc' }); + quiet.replace([ + { center: [1, 1], radiusM: 1e5, fill: [1, 1, 1, 1], pickId: 'q' }, + ]); + quiet.commit(); + states.push(...live.values()); + let ground0 = geo.cartesian3.fromDegrees(1, 1); + const picks = createGroundPicks({ + pickGlobe: () => ground0, + camera: { + livePosition: geo.cartesian3.fromDegrees(1, 1, 1e5), + pose: () => ({ fovy: Math.PI / 3 }), + }, + size: () => ({ width: 1000, height: 1000 }), + sets: () => states, + }); + const ids = () => picks({ x: 0, y: 0 }).map(({ result }) => result.id); + assert.deepEqual(ids(), ['b', 'a'], 'the later item first; quiet left out'); + ground0 = geo.cartesian3.fromDegrees(0.2, 0.2); + assert.deepEqual(ids(), ['a']); + ground0 = geo.cartesian3.fromDegrees(1, 3.005); + assert.deepEqual(ids(), ['road']); + ground0 = geo.cartesian3.fromDegrees(1, 3.05); + assert.deepEqual(ids(), []); + const [hit] = picks({ x: 0, y: 0 }).concat( + (ground0 = geo.cartesian3.fromDegrees(1, 1)) && picks({ x: 0, y: 0 }), + ); + assert.equal(hit.onTop, -1, 'under every mover and line'); + assert.deepEqual(hit.result, { id: 'b', owner: 'low', instance: 1 }); + low.show(false); + assert.deepEqual(ids(), []); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 0af11156e..2c8318947 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -27,6 +27,7 @@ import { createNavaraCredits } from './credits.js'; import { createNavaraInput } from './input.js'; import { createNavaraPicking } from './picking.js'; import { createFollowController } from '../follow.js'; +import { createGroundPicks } from '../ground.js'; import { navaraFollowGestures } from './followGestures.js'; import { createNavaraMaps } from './maps.js'; import { createNavaraSurface } from './surface.js'; @@ -47,6 +48,12 @@ const NO_LINES = Object.freeze({ }, list: () => [], }); +const NO_GROUND = Object.freeze({ + createSet() { + throw new Error("Navara ground needs the view's three runtime (ground/)"); + }, + list: () => [], +}); // Navara's drawn camera (view.camera.raw, a three PerspectiveCamera in // Earth-fixed metres): matrixWorld columns are right, up, back, position. @@ -89,7 +96,8 @@ function readCamera(raw) { * `appClock` (the engine clock's time) defaults to the page's app clock; * `movers` is engine.movers on the view's three runtime (movers/index.js; * without it createSet throws), `lines` engine.lines (lines/index.js; - * likewise), both destroyed with the engine; + * likewise), `ground` engine.ground (ground/index.js; likewise), all + * destroyed with the engine; * `fetch` and `clock` (the camera model's timers) are test seams. * @returns {import('../contract.js').GlobeEngine} */ @@ -103,6 +111,7 @@ export function createNavaraEngine( appClock, movers: moversPort = null, lines: linesPort = null, + ground: groundPort = null, sharpen: sharpenPort = null, styles: stylesPort = null, bloom: bloomPort = null, @@ -228,6 +237,16 @@ export function createNavaraEngine( ...(clock ? { now: () => clock.now() } : {}), }); + // Ground items are picked on the CPU at the globe under the pointer + // (src/globe/ground.js), under every mover and line. + const removeGroundPicks = picking.registerMeshPicker( + createGroundPicks({ + pickGlobe: (screen) => picker.pickGlobe(screen), + camera: model.camera, + size, + sets: () => groundPort?.pickables?.() ?? [], + }), + ); const picker = { // Navara mesh owners supply exact triangle hits for sync/hover/click picks. registerMeshPicker: (pick) => picking.registerMeshPicker(pick), @@ -394,6 +413,7 @@ export function createNavaraEngine( clock: engineClock.clock, movers: moversPort?.movers ?? NO_MOVERS, lines: linesPort?.lines ?? NO_LINES, + ground: groundPort?.ground ?? NO_GROUND, destroy() { if (destroyed) return; destroyed = true; @@ -402,6 +422,7 @@ export function createNavaraEngine( follow.destroy(); model.destroy(); input.destroy(); + removeGroundPicks(); picking.destroy(); credits.destroy(); maps.destroy(); @@ -412,6 +433,7 @@ export function createNavaraEngine( sonarPort?.destroy(); moversPort?.destroy(); linesPort?.destroy(); + groundPort?.destroy(); }, isDestroyed: () => destroyed, // Navara only, not contract: the post pipeline as QA reads it (the @@ -426,6 +448,8 @@ export function createNavaraEngine( passes: styles?.passes ?? glow?.passes ?? null, }; }, + // Navara only, not contract: what the ground pass holds (QA reads it). + groundDiagnostics: () => groundPort?.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/index.js b/src/globe/navara/ground/index.js new file mode 100644 index 000000000..903d62509 --- /dev/null +++ b/src/globe/navara/ground/index.js @@ -0,0 +1,107 @@ +// engine.ground on Navara: every shown set in one screen pass (pass.js), +// classified on the scene depth, so fills drape on terrain and 3D tiles +// alike. A commit packs the set's items once (pack.js), with the terrain +// heights under them that bound each item's screen box; a set packed before +// its terrain loaded is packed again a few times, further apart each time, +// until it has them. A show, hide or commit composes the shown sets in draw +// order (set order, then creation) and asks for one frame. Nothing animates +// on its own. Picks are CPU picks at the globe under the pointer +// (src/globe/ground.js), wired by the engine. + +import { createGroundRegistry } from '../../ground.js'; +import { composeGround, packSet } from './pack.js'; + +// Frames before the first new try at a set's missing terrain heights; each +// later try waits twice as long, up to RETRIES tries. +const RETRY_FRAMES = 30; +const RETRIES = 6; + +/** + * @param {object} view an initialised Navara ThreeView + * @param {{ createPass: (view: object) => ReturnType }} options + * `createPass` makes the pass (pass.js createGroundPass; the view's three + * runtime, which Node tests replace). + * @returns {{ ground: import('../../contract.js').GlobeGround, + * pickables: () => object[], diagnostics: () => object, destroy: () => void }} + */ +export function createNavaraGround(view, { createPass }) { + const pass = createPass(view); + const entries = new Set(); + const terrainHeight = (lon, lat) => { + const height = view.sampleTerrainHeight?.({ lat, lng: lon }); + return Number.isFinite(height) ? height : undefined; + }; + const pack = (entry) => { + entry.pack = packSet(entry.options.kind, entry.items, { terrainHeight }); + entry.wait = entry.pack.heights.some(Number.isNaN) ? RETRY_FRAMES : 0; + }; + const compose = () => { + const drawn = [...entries] + .filter((entry) => entry.shown && entry.pack?.count) + .sort((a, b) => a.options.order - b.options.order); + pass.set( + drawn.length ? composeGround(drawn.map(({ pack }) => pack)) : null, + ); + view.forceUpdate(); + }; + const retry = () => { + let changed = false; + for (const entry of entries) { + if (!entry.wait || !entry.shown || --entry.wait > 0) continue; + const tries = entry.tries + 1; + pack(entry); + entry.tries = tries; + if (entry.wait) + entry.wait = tries < RETRIES ? RETRY_FRAMES * 2 ** tries : 0; + changed = true; + } + if (changed) compose(); + }; + view.on('postRender', retry); + const registry = createGroundRegistry((options) => { + const entry = { + options, + shown: true, + items: [], + pack: null, + tries: 0, + wait: 0, + }; + entries.add(entry); + return { + commit(items) { + entry.items = items; + entry.tries = 0; + pack(entry); + compose(); + }, + show(visible) { + if (entry.shown === visible) return; + entry.shown = visible; + compose(); + }, + destroy() { + entries.delete(entry); + // A set object still held keeps no packed data. + entry.items = []; + entry.pack = null; + compose(); + }, + }; + }); + return { + ground: registry.ground, + /** Every live set's state ({ options, items, shown }) for CPU picks. */ + pickables: () => [...registry.live.values()], + /** Not contract: what the pass holds (QA measures it). */ + diagnostics: () => ({ + items: pass.owner.state?.count ?? 0, + textureBytes: pass.textureBytes(), + }), + destroy() { + view.off('postRender', retry); + registry.destroyAll(); + pass.destroy(); + }, + }; +} diff --git a/src/globe/navara/ground/index.test.mjs b/src/globe/navara/ground/index.test.mjs new file mode 100644 index 000000000..1de4213ca --- /dev/null +++ b/src/globe/navara/ground/index.test.mjs @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createNavaraGround } from './index.js'; + +function fakeView({ height = () => 12 } = {}) { + const listeners = new Map(); + return { + frames: 0, + on: (name, listener) => listeners.set(name, listener), + off: (name) => listeners.delete(name), + forceUpdate() { + this.frames++; + }, + sampleTerrainHeight: ({ lat, lng }) => height(lng, lat), + postRender: () => listeners.get('postRender')?.(), + listening: () => listeners.size, + }; +} + +function fakePass() { + const states = []; + return { + states, + owner: { state: null }, + set(state) { + states.push(state); + this.owner.state = state; + }, + textureBytes: () => 0, + destroyed: false, + destroy() { + this.destroyed = true; + }, + }; +} + +const square = (lon) => ({ + rings: [ + [ + [lon, 0], + [lon + 1, 0], + [lon + 1, 1], + ], + ], + fill: [1, 0, 0, 1], +}); + +test('shown sets compose in order into one pass; hiding all hides it', () => { + const view = fakeView(); + const pass = fakePass(); + const ground = createNavaraGround(view, { createPass: () => pass }); + const top = ground.ground.createSet({ id: 'top', kind: 'polygon', order: 1 }); + const bottom = ground.ground.createSet({ id: 'bottom', kind: 'polygon' }); + top.replace([square(0)]); + top.commit(); + bottom.replace([square(2), square(4)]); + bottom.commit(); + const state = pass.states.at(-1); + assert.equal(state.count, 3); + assert.ok(view.frames >= 2, 'each change asks for a frame'); + top.show(false); + bottom.show(false); + assert.equal(pass.states.at(-1), null); + bottom.show(true); + assert.equal(pass.states.at(-1).count, 2); + assert.equal(ground.pickables().length, 2); + ground.destroy(); + assert.equal(pass.destroyed, true); + assert.equal(view.listening(), 0); +}); + +test('a set packed before its terrain loaded is packed again later', () => { + let loaded = false; + const view = fakeView({ height: () => (loaded ? 30 : undefined) }); + const pass = fakePass(); + const ground = createNavaraGround(view, { createPass: () => pass }); + const set = ground.ground.createSet({ id: 'zones', kind: 'polygon' }); + set.replace([square(0)]); + set.commit(); + const before = pass.states.length; + for (let frame = 0; frame < 29; frame++) view.postRender(); + assert.equal(pass.states.length, before, 'not before 30 frames'); + loaded = true; + view.postRender(); + assert.equal(pass.states.length, before + 1, 'packed again with heights'); + for (let frame = 0; frame < 500; frame++) view.postRender(); + assert.equal(pass.states.length, before + 1, 'no more once it has them'); + ground.destroy(); +}); diff --git a/src/globe/navara/ground/pack.js b/src/globe/navara/ground/pack.js new file mode 100644 index 000000000..37933a340 --- /dev/null +++ b/src/globe/navara/ground/pack.js @@ -0,0 +1,616 @@ +// The data of Navara's ground pass (pass.js), without three: each item in a +// gnomonic frame of its own (the tangent plane at its centre, reached along +// rays from the Earth's centre, where great-circle edges are straight +// lines), its boundary as segments in that plane, in float64 until the +// numbers are small; per camera change the frames in view space, each +// item's conservative screen box and a screen grid of candidate items, +// kept in draw order. + +import * as geo from '../../../geo/index.js'; +import { cellRing } from '../../ground.js'; + +const { cartesian3 } = geo; + +/** Item kinds as the shader reads them. */ +export const KIND = Object.freeze({ + area: 0, + disc: 1, + corridorPx: 2, + corridorM: 3, +}); +export const GRID_COLS = 64; +export const GRID_ROWS = 40; +/** RGBA32F texels per pass item in the frame texture. */ +export const TEXELS = 4; +/** RGBA32F texels per pass item in the style texture. */ +export const STYLE_TEXELS = 6; +/** An area of more segments than this gets a grid of bins (binArea). */ +export const BIN_THRESHOLD = 32; +const MAX_BINS = 32; +/** Segments of a corridor drawn as one pass item. */ +export const CORRIDOR_CHUNK = 4; +/** Texture rows hold this many texels. */ +export const ROW = 1024; + +// Highest ground above the ellipsoid a fill reaches (the item's volume). +const CEILING_M = 9000; +// Lowest: Navara's globe mesh can lie well under the ellipsoid at orbital +// detail (−26 km measured over Alaska); Cesium's GroundPrimitive floor. +const FLOOR_M = -100000; +// The floor of an item's screen box: as deep as the terrain's level of +// detail can put the ground at that distance (a kilometre plus 5 % of the +// distance), down to FLOOR_M. A box to −100 km for a near item streaks +// across the screen and makes it a candidate of cells it never covers. +const nearFloor = (distance) => -Math.min(-FLOOR_M, 1000 + 0.05 * distance); +// Where the terrain under an item was sampled, its box spans the sampled +// heights, less the error of the terrain's level of detail at that distance +// (100 m plus 2 %) below, and above that error plus room for 3D tiles' +// buildings over the terrain. +// Terrain is sampled on a grid of 0.005° (about 500 m), and the box +// widened by 350 m each way for the slopes between samples. +const SAMPLE_STEP_DEG = 0.005; +const lodMargin = (distance) => 450 + 0.02 * distance; +const TILE_HEADROOM_M = 900; +/** Terrain samples per pass item. */ +const SAMPLES = 9; +const CELL_STEP_DEG = 0.25; + +function frameAt(lon, lat) { + const center = cartesian3.fromDegrees(lon, lat); + const radius = cartesian3.magnitude(center); + const up = cartesian3.divideByScalar(center, radius, cartesian3.create()); + const east = cartesian3.normalize( + cartesian3.create(-center.y, center.x, 0), + cartesian3.create(), + ); + if (!Number.isFinite(east.x)) Object.assign(east, { x: 1, y: 0, z: 0 }); + const north = cartesian3.cross(up, east, cartesian3.create()); + const scratch = cartesian3.create(); + // A ground position in the frame's gnomonic plane, metres. + const project = (lonDeg, latDeg) => { + cartesian3.fromDegrees(lonDeg, latDeg, 0, undefined, scratch); + const dx = scratch.x - center.x; + const dy = scratch.y - center.y; + const dz = scratch.z - center.z; + const scale = radius / (radius + dx * up.x + dy * up.y + dz * up.z); + return [ + (dx * east.x + dy * east.y + dz * east.z) * scale, + (dx * north.x + dy * north.y + dz * north.z) * scale, + ]; + }; + return { center, radius, up, east, north, project }; +} + +// A path's points in a frame's plane. +const planePoints = (flat, project) => { + const points = []; + for (let index = 0; index < flat.length; index += 2) + points.push(project(flat[index], flat[index + 1])); + return points; +}; + +/** + * A grid of square bins over an area's segments (flat [ax, ay, bx, by, …] + * in its plane) when it has more than BIN_THRESHOLD: the grid covers the + * segments' bounds and half a bin round them; each bin lists the segments + * that reach within half a bin of it and whether its centre is inside the + * area (even-odd). A pixel then tests only its bin's segments: the nearest + * one, and the crossings between it and the bin's centre. Null for a + * smaller area. + */ +export function binArea(list) { + const count = list.length / 4; + if (count <= BIN_THRESHOLD) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (let index = 0; index < list.length; index += 2) { + minX = Math.min(minX, list[index]); + maxX = Math.max(maxX, list[index]); + minY = Math.min(minY, list[index + 1]); + maxY = Math.max(maxY, list[index + 1]); + } + const across = Math.min( + MAX_BINS, + Math.max(2, Math.ceil(Math.sqrt(count / 4))), + ); + const size = Math.max(maxX - minX, maxY - minY, 1e-3) / across; + const x = minX - size / 2; + const y = minY - size / 2; + const columns = Math.ceil((maxX - minX + size) / size) || 1; + const rows = Math.ceil((maxY - minY + size) / size) || 1; + const cells = Array.from({ length: columns * rows }, () => []); + const clampTo = (value, max) => Math.min(max - 1, Math.max(0, value)); + for (let index = 0; index < count; index++) { + const [ax, ay, bx, by] = list.slice(index * 4, index * 4 + 4); + const x0 = clampTo( + Math.floor((Math.min(ax, bx) - x) / size - 0.5), + columns, + ); + const x1 = clampTo( + Math.floor((Math.max(ax, bx) - x) / size + 0.5), + columns, + ); + const y0 = clampTo(Math.floor((Math.min(ay, by) - y) / size - 0.5), rows); + const y1 = clampTo(Math.floor((Math.max(ay, by) - y) / size + 0.5), rows); + for (let row = y0; row <= y1; row++) + for (let column = x0; column <= x1; column++) + cells[row * columns + column].push(index); + } + const insideAt = (px, py) => { + let inside = false; + for (let index = 0; index < list.length; index += 4) { + const ay = list[index + 1]; + const by = list[index + 3]; + if ( + ay > py !== by > py && + px < + ((list[index + 2] - list[index]) * (py - ay)) / (by - ay) + + list[index] + ) + inside = !inside; + } + return inside; + }; + return { + x, + y, + size, + columns, + rows, + bins: cells.map((indices, cell) => ({ + count: indices.length, + inside: insideAt( + x + ((cell % columns) + 0.5) * size, + y + (Math.floor(cell / columns) + 0.5) * size, + ) + ? 1 + : 0, + list: indices.flatMap((index) => list.slice(index * 4, index * 4 + 4)), + })), + }; +} + +/** + * One set's items packed for the pass as pass items: float64 frames, the + * terrain heights under each (`terrainHeight(lon, lat)`, undefined where + * not loaded: that item keeps the deep default box), the + * style texels (segment starts relative to the set's own segments) and the + * segments. A corridor longer than CORRIDOR_CHUNK segments becomes one pass + * item per chunk, each in its own plane with its own screen box; a chunk + * also holds its neighbours' end segments and draws only where its own + * segments are the nearest, so joints blend once. + */ +export function packSet(kind, items, { terrainHeight = null } = {}) { + const frames = []; + const styles = []; + const volumes = []; + const heights = []; + const segments = []; + const bins = []; + // Terrain heights on a grid of SAMPLE_STEP_DEG, each sampled once. + const sampled = new Map(); + const heightAt = (lon, lat) => { + const x = Math.round(lon / SAMPLE_STEP_DEG); + const y = Math.round(lat / SAMPLE_STEP_DEG); + const key = x * 1e6 + y; + if (!sampled.has(key)) + sampled.set( + key, + terrainHeight?.(x * SAMPLE_STEP_DEG, y * SAMPLE_STEP_DEG), + ); + return sampled.get(key); + }; + // The lowest and highest terrain under a pass item's sample positions + // (flat [lon, lat, …], at most SAMPLES of them), NaN when unknown. + const sampleHeights = (flat) => { + if (!terrainHeight) return [Number.NaN, Number.NaN]; + let low = Infinity; + let high = -Infinity; + const count = flat.length / 2; + const step = Math.max(1, Math.ceil(count / SAMPLES)); + for (let index = 0; index < count; index += step) { + const height = heightAt(flat[index * 2], flat[index * 2 + 1]); + if (!Number.isFinite(height)) return [Number.NaN, Number.NaN]; + low = Math.min(low, height); + high = Math.max(high, height); + } + return [low, high]; + }; + const emit = (frame, style, extent, padPx, list, samples, extra = null) => { + heights.push(...sampleHeights(samples)); + const start = segments.length / 4; + segments.push(...list); + if (extra) { + // The bins' own segment lists follow the item's, relative to its start. + style.bins[3] = bins.length / 4; + for (const bin of extra.bins) { + bins.push(segments.length / 4 - start, bin.count, bin.inside, 0); + segments.push(...bin.list); + } + } + const radius = extent * (1 + 101000 / frame.radius) + 1; + const floor = FLOOR_M - ((radius * radius) / (2 * frame.radius)) * 1.1; + frames.push( + frame.center.x, + frame.center.y, + frame.center.z, + frame.east.x, + frame.east.y, + frame.east.z, + frame.north.x, + frame.north.y, + frame.north.z, + frame.up.x, + frame.up.y, + frame.up.z, + frame.radius, + ); + volumes.push(radius, floor, CEILING_M, padPx + 2); + styles.push( + ...style.fill, + ...style.line, + ...style.shape, + floor, + CEILING_M, + start, + list.length / 4, + ...style.bins, + ...style.origin, + ); + }; + const segmentsOf = (points, closed) => { + const list = []; + const last = closed ? points.length : points.length - 1; + for (let index = 0; index < last; index++) { + const a = points[index]; + const b = points[(index + 1) % points.length]; + list.push(a[0], a[1], b[0], b[1]); + } + return list; + }; + const reach = (points) => + points.reduce((most, [x, y]) => Math.max(most, Math.hypot(x, y)), 0); + for (const item of items) { + const none = [0, 0, 0, 0]; + if (kind === 'corridor') { + const code = item.widthM ? KIND.corridorM : KIND.corridorPx; + const segmentCount = item.path.length / 2 - 1; + for (let first = 0; first < segmentCount; first += CORRIDOR_CHUNK) { + const own = Math.min(CORRIDOR_CHUNK, segmentCount - first); + const from = Math.max(0, first - 1); + const to = Math.min(segmentCount, first + own + 1); + const part = item.path.subarray(from * 2, (to + 1) * 2); + let west = Infinity; + let east = -Infinity; + let south = Infinity; + let north = -Infinity; + for (let at = 0; at < part.length; at += 2) { + west = Math.min(west, part[at]); + east = Math.max(east, part[at]); + south = Math.min(south, part[at + 1]); + north = Math.max(north, part[at + 1]); + } + const frame = frameAt((west + east) / 2, (south + north) / 2); + const points = planePoints(part, frame.project); + emit( + frame, + { + fill: item.fill, + line: none, + shape: [code, 0, item.widthPx ?? 0, item.widthM ?? 0], + bins: [first - from, own, 0, 0], + origin: [0, 0, 0, 0], + }, + reach(points) + (item.widthM ?? 0) / 2, + (item.widthPx ?? 0) / 2, + segmentsOf(points, false), + part, + ); + } + continue; + } + const style = { + fill: item.fill ?? none, + line: item.outline?.color ?? none, + shape: [KIND.area, item.outline?.widthPx ?? 0, 0, 0], + bins: [0, 0, 0, 0], + origin: [0, 0, 0, 0], + }; + const padPx = (item.outline?.widthPx ?? 0) / 2; + if (kind === 'disc') { + const frame = frameAt(item.center[0], item.center[1]); + const radius = frame.radius * Math.tan(item.radiusM / frame.radius); + style.shape = [KIND.disc, item.outline?.widthPx ?? 0, 0, radius]; + emit( + frame, + style, + radius, + padPx, + [], + Float64Array.of( + ...item.center, + item.bounds.west, + item.center[1], + item.bounds.east, + item.center[1], + item.center[0], + item.bounds.south, + item.center[0], + item.bounds.north, + ), + ); + continue; + } + const { bounds } = item; + const frame = frameAt( + (bounds.west + bounds.east) / 2, + (bounds.south + bounds.north) / 2, + ); + const rings = ( + kind === 'cell' ? [cellRing(item, CELL_STEP_DEG)] : item.rings + ).map((ring) => planePoints(ring, frame.project)); + const list = rings.flatMap((ring) => segmentsOf(ring, true)); + const binned = binArea(list); + if (binned) { + style.bins = [binned.columns, binned.rows, binned.size, 0]; + style.origin = [binned.x, binned.y, 0, 0]; + } + emit( + frame, + style, + Math.max(...rings.map(reach)), + padPx, + list, + Float64Array.of( + (bounds.west + bounds.east) / 2, + (bounds.south + bounds.north) / 2, + ...(kind === 'cell' ? cellRing(item, CELL_STEP_DEG) : item.rings[0]), + ), + binned, + ); + } + return { + count: volumes.length / 4, + bins: Float32Array.from(bins), + heights: Float64Array.from(heights), + frames: Float64Array.from(frames), + styles: Float32Array.from(styles), + volumes: Float64Array.from(volumes), + segments: Float32Array.from(segments), + }; +} + +/** Storage for `texels` RGBA texels in rows of ROW. */ +export function texelStorage(texels) { + const width = Math.min(ROW, Math.max(1, texels)); + const height = Math.max(1, Math.ceil(texels / width)); + return { width, height, data: new Float32Array(width * height * 4) }; +} + +/** Conservative clip box of an item's volume in view space, or 2s off screen. */ +export function clipBounds(view, radius, floor, ceiling, projection, out) { + const p = projection; + let x0 = Infinity; + let y0 = Infinity; + let x1 = -Infinity; + let y1 = -Infinity; + let front = 0; + let behind = 0; + for (let corner = 0; corner < 8; corner++) { + const x = (corner & 1 ? 1 : -1) * radius; + const y = (corner & 2 ? 1 : -1) * radius; + const z = corner & 4 ? ceiling : floor; + const vx = view[0] + view[3] * x + view[6] * y + view[9] * z; + const vy = view[1] + view[4] * x + view[7] * y + view[10] * z; + const vz = view[2] + view[5] * x + view[8] * y + view[11] * z; + const w = p[3] * vx + p[7] * vy + p[11] * vz + p[15]; + if (w <= 0) { + behind++; + continue; + } + front++; + const cx = (p[0] * vx + p[4] * vy + p[8] * vz + p[12]) / w; + const cy = (p[1] * vx + p[5] * vy + p[9] * vz + p[13]) / w; + x0 = Math.min(x0, cx); + y0 = Math.min(y0, cy); + x1 = Math.max(x1, cx); + y1 = Math.max(y1, cy); + } + if (front && behind) { + x0 = -1; + y0 = -1; + x1 = 1; + y1 = 1; + } + if (!front || x1 < -1 || y1 < -1 || x0 > 1 || y0 > 1) out.fill(2); + else { + out[0] = Math.max(-1, x0); + out[1] = Math.max(-1, y0); + out[2] = Math.min(1, x1); + out[3] = Math.min(1, y1); + } + return out; +} + +/** + * The drawn sets (packSet results, in draw order) as one pass state: + * static textures data (styles, segments) and update(view, projection, + * width, height), which refreshes the frames in view space and the grid + * of candidates when the camera or the viewport changed (true then). + */ +export function composeGround(packs) { + const count = packs.reduce((total, pack) => total + pack.count, 0); + const segmentCount = packs.reduce( + (total, pack) => total + pack.segments.length / 4, + 0, + ); + const frames64 = new Float64Array(count * 13); + const volumes = new Float64Array(count * 4); + const heights = new Float64Array(count * 2); + const styles = texelStorage(count * STYLE_TEXELS); + const segments = texelStorage(segmentCount); + const bins = texelStorage( + packs.reduce((total, pack) => total + pack.bins.length / 4, 0), + ); + let item = 0; + let segment = 0; + let bin = 0; + for (const pack of packs) { + frames64.set(pack.frames, item * 13); + volumes.set(pack.volumes, item * 4); + heights.set(pack.heights, item * 2); + styles.data.set(pack.styles, item * STYLE_TEXELS * 4); + for (let index = 0; index < pack.count; index++) { + const at = (item + index) * STYLE_TEXELS * 4; + styles.data[at + 14] += segment; + // A binned area's first bin. + if (styles.data[at + 8] === KIND.area && styles.data[at + 16] > 0) + styles.data[at + 19] += bin; + } + segments.data.set(pack.segments, segment * 4); + bins.data.set(pack.bins, bin * 4); + item += pack.count; + segment += pack.segments.length / 4; + bin += pack.bins.length / 4; + } + const frames = texelStorage(count * TEXELS); + const grid = { + width: GRID_COLS, + height: GRID_ROWS, + data: new Float32Array(GRID_COLS * GRID_ROWS * 4), + }; + let candidates = texelStorage(1); + const counts = new Uint32Array(GRID_COLS * GRID_ROWS); + const offsets = new Uint32Array(counts.length); + const cursor = new Uint32Array(counts.length); + const ranges = new Int32Array(count * 4); + const viewFrame = new Float64Array(12); + const box = new Float64Array(4); + const lastView = new Float64Array(16).fill(Number.NaN); + const lastProjection = new Float64Array(16).fill(Number.NaN); + let lastWidth = 0; + let lastHeight = 0; + const clampTo = (value, max) => Math.min(max - 1, Math.max(0, value)); + + return { + count, + styles, + segments, + bins, + frames, + grid, + get candidates() { + return candidates; + }, + /** True when the frames, grid or candidates changed (upload them). */ + update(view, projection, width, height) { + let changed = width !== lastWidth || height !== lastHeight; + for (let index = 0; index < 16 && !changed; index++) + changed = + view[index] !== lastView[index] || + projection[index] !== lastProjection[index]; + if (!changed) return false; + lastView.set(view); + lastProjection.set(projection); + lastWidth = width; + lastHeight = height; + const m = view; + counts.fill(0); + for (let index = 0; index < count; index++) { + const f = index * 13; + // Centre with the translation, axes without; float64 until here. + for (let axis = 0; axis < 4; axis++) { + const x = frames64[f + axis * 3]; + const y = frames64[f + axis * 3 + 1]; + const z = frames64[f + axis * 3 + 2]; + const translate = axis === 0 ? 1 : 0; + viewFrame[axis * 3] = + m[0] * x + m[4] * y + m[8] * z + translate * m[12]; + viewFrame[axis * 3 + 1] = + m[1] * x + m[5] * y + m[9] * z + translate * m[13]; + viewFrame[axis * 3 + 2] = + m[2] * x + m[6] * y + m[10] * z + translate * m[14]; + } + const t = index * TEXELS * 4; + frames.data.set(viewFrame.subarray(0, 3), t); + frames.data[t + 3] = frames64[f + 12]; + frames.data.set(viewFrame.subarray(3, 6), t + 4); + frames.data.set(viewFrame.subarray(6, 9), t + 8); + frames.data.set(viewFrame.subarray(9, 12), t + 12); + const v = index * 4; + // The item's extent in its plane and its padding in pixels, for + // the shader's early rejection. + frames.data[t + 7] = volumes[v]; + frames.data[t + 11] = volumes[v + 3]; + const distance = Math.hypot(viewFrame[0], viewFrame[1], viewFrame[2]); + // Heights relative to the plane's origin on the ellipsoid; the + // plane dips under the curve by r²/2R at the item's edge. + const low = heights[index * 2]; + const known = Number.isFinite(low); + const dip = (volumes[v] * volumes[v]) / (2 * frames64[f + 12]); + clipBounds( + viewFrame, + volumes[v], + known + ? low - lodMargin(distance) - dip * 1.1 + : Math.max(volumes[v + 1], nearFloor(distance)), + known + ? heights[index * 2 + 1] + TILE_HEADROOM_M + lodMargin(distance) + : volumes[v + 2], + projection, + box, + ); + const r = index * 4; + const valid = box[0] <= 1; + if (!valid) { + ranges[r] = -1; + continue; + } + const padX = (2 * volumes[v + 3]) / Math.max(1, width); + const padY = (2 * volumes[v + 3]) / Math.max(1, height); + ranges[r] = clampTo( + Math.floor(((box[0] - padX) * 0.5 + 0.5) * GRID_COLS), + GRID_COLS, + ); + ranges[r + 1] = clampTo( + Math.floor(((box[1] - padY) * 0.5 + 0.5) * GRID_ROWS), + GRID_ROWS, + ); + ranges[r + 2] = clampTo( + Math.floor(((box[2] + padX) * 0.5 + 0.5) * GRID_COLS), + GRID_COLS, + ); + ranges[r + 3] = clampTo( + Math.floor(((box[3] + padY) * 0.5 + 0.5) * GRID_ROWS), + GRID_ROWS, + ); + for (let y = ranges[r + 1]; y <= ranges[r + 3]; y++) + for (let x = ranges[r]; x <= ranges[r + 2]; x++) + counts[y * GRID_COLS + x]++; + } + let total = 0; + for (let cell = 0; cell < counts.length; cell++) { + offsets[cell] = total; + cursor[cell] = total; + total += counts[cell]; + grid.data[cell * 4] = offsets[cell]; + grid.data[cell * 4 + 1] = counts[cell]; + } + if (candidates.data.length / 4 < total) + candidates = texelStorage( + Math.max(total, (candidates.data.length / 4) * 2), + ); + // Items enter each cell in draw order. + for (let index = 0; index < count; index++) { + const r = index * 4; + if (ranges[r] < 0) continue; + for (let y = ranges[r + 1]; y <= ranges[r + 3]; y++) + for (let x = ranges[r]; x <= ranges[r + 2]; x++) + candidates.data[cursor[y * GRID_COLS + x]++ * 4] = index; + } + return true; + }, + }; +} diff --git a/src/globe/navara/ground/pack.test.mjs b/src/globe/navara/ground/pack.test.mjs new file mode 100644 index 000000000..0d40a860b --- /dev/null +++ b/src/globe/navara/ground/pack.test.mjs @@ -0,0 +1,273 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../../geo/index.js'; +import { normalizeGroundItem } from '../../ground.js'; +import { + BIN_THRESHOLD, + binArea, + composeGround, + CORRIDOR_CHUNK, + GRID_COLS, + KIND, + packSet, + STYLE_TEXELS, +} from './pack.js'; + +const items = (kind, list) => + list.map((item, index) => normalizeGroundItem(kind, item, index)); + +test('a set packs its frames, styles and segments', () => { + const pack = packSet( + 'polygon', + items('polygon', [ + { + rings: [ + [ + [55, 25], + [55.1, 25], + [55.1, 25.1], + ], + [ + [55.05, 25.02], + [55.08, 25.02], + [55.08, 25.05], + ], + ], + fill: [1, 0, 0, 0.5], + outline: { color: [0, 0, 1, 1], widthPx: 3 }, + }, + ]), + ); + assert.equal(pack.count, 1); + assert.equal(pack.segments.length / 4, 6, 'three segments per ring'); + const style = pack.styles; + assert.deepEqual([...style.slice(0, 4)], [1, 0, 0, 0.5]); + assert.deepEqual([...style.slice(4, 8)], [0, 0, 1, 1]); + assert.equal(style[8], KIND.area); + assert.equal(style[9], 3); + assert.equal(style[14], 0); + assert.equal(style[15], 6); + // The plane's origin is on the ellipsoid at the bounds' centre. + const centre = geo.cartesian3.fromDegrees(55.05, 25.05); + assert.ok(Math.abs(pack.frames[0] - centre.x) < 1e-6); + assert.equal(pack.volumes[3], 3 / 2 + 2, 'half the outline + 2 px pad'); +}); + +test('gnomonic segments keep a great-circle edge straight and distances right', () => { + const [disc] = items('disc', [{ center: [0, 0], radiusM: 100_000 }]); + const pack = packSet('disc', [disc]); + const radius = pack.frames[12]; + assert.ok( + Math.abs(pack.styles[11] - radius * Math.tan(100_000 / radius)) < 0.01, + ); + assert.equal(pack.styles[8], KIND.disc); + const corridor = packSet( + 'corridor', + items('corridor', [ + { + positions: [ + [-1, 0], + [1, 0], + ], + color: [1, 1, 1, 1], + widthPx: 4, + }, + ]), + ); + const [ax, ay, bx, by] = corridor.segments; + assert.ok(Math.abs(ay) < 1e-6 && Math.abs(by) < 1e-6, 'on the equator'); + assert.ok(Math.abs(ax + bx) < 1e-6); + assert.equal(corridor.styles[8], KIND.corridorPx); + assert.equal(corridor.styles[10], 4); +}); + +test('composed sets keep draw order and offset their segments', () => { + const square = (lon) => ({ + rings: [ + [ + [lon, 0], + [lon + 1, 0], + [lon + 1, 1], + [lon, 1], + ], + ], + fill: [1, 1, 1, 1], + }); + const first = packSet('polygon', items('polygon', [square(0)])); + const second = packSet('polygon', items('polygon', [square(0.5)])); + const state = composeGround([first, second]); + assert.equal(state.count, 2); + assert.equal( + state.styles.data[STYLE_TEXELS * 4 + 14], + 4, + 'second set after four', + ); + // A camera 2000 km over (0.75, 0.5) looking straight down. + const eye = geo.cartesian3.fromDegrees(0.75, 0.5, 2e6); + const target = geo.cartesian3.fromDegrees(0.75, 0.5); + const up = geo.cartesian3.create(0, 0, 1); + const zAxis = geo.cartesian3.normalize( + geo.cartesian3.subtract(eye, target, geo.cartesian3.create()), + geo.cartesian3.create(), + ); + const xAxis = geo.cartesian3.normalize( + geo.cartesian3.cross(up, zAxis, geo.cartesian3.create()), + geo.cartesian3.create(), + ); + const yAxis = geo.cartesian3.cross(zAxis, xAxis, geo.cartesian3.create()); + const view = [ + xAxis.x, + yAxis.x, + zAxis.x, + 0, + xAxis.y, + yAxis.y, + zAxis.y, + 0, + xAxis.z, + yAxis.z, + zAxis.z, + 0, + -geo.cartesian3.dot(xAxis, eye), + -geo.cartesian3.dot(yAxis, eye), + -geo.cartesian3.dot(zAxis, eye), + 1, + ]; + const f = 1 / Math.tan(Math.PI / 6); + const near = 1; + const far = 1e8; + const projection = [ + f, + 0, + 0, + 0, + 0, + f, + 0, + 0, + 0, + 0, + -(far + near) / (far - near), + -1, + 0, + 0, + (-2 * far * near) / (far - near), + 0, + ]; + assert.equal(state.update(view, projection, 800, 800), true); + assert.equal(state.update(view, projection, 800, 800), false, 'unchanged'); + // The middle cell holds both, first set first. + const cell = (GRID_COLS * 40) / 2 + GRID_COLS / 2; + const [offset, count] = state.grid.data.slice(cell * 4, cell * 4 + 2); + assert.equal(count, 2); + assert.deepEqual( + [ + state.candidates.data[offset * 4], + state.candidates.data[(offset + 1) * 4], + ], + [0, 1], + ); + // The first frame's centre in view space is in front of the camera. + assert.ok(state.frames.data[2] < -1.9e6); +}); + +test('a long corridor packs as chunks that own their own segments', () => { + const positions = Array.from({ length: 41 }, (_, index) => [index * 0.01, 0]); + const pack = packSet( + 'corridor', + items('corridor', [{ positions, color: [1, 1, 1, 1], widthM: 10 }]), + ); + assert.equal(pack.count, Math.ceil(40 / CORRIDOR_CHUNK)); + const owned = (index) => [ + ...pack.styles.slice( + index * STYLE_TEXELS * 4 + 16, + index * STYLE_TEXELS * 4 + 18, + ), + ]; + const counts = (index) => pack.styles[index * STYLE_TEXELS * 4 + 15]; + // The first chunk has no segment before it, the others one each side. + assert.deepEqual(owned(0), [0, CORRIDOR_CHUNK]); + assert.equal(counts(0), CORRIDOR_CHUNK + 1); + assert.deepEqual(owned(1), [1, CORRIDOR_CHUNK]); + assert.equal(counts(1), CORRIDOR_CHUNK + 2); + const last = pack.count - 1; + const rest = 40 - last * CORRIDOR_CHUNK; + assert.deepEqual(owned(last), [1, rest]); + assert.equal(counts(last), rest + 1); + assert.equal(pack.styles[8], KIND.corridorM); + assert.equal(pack.styles[11], 10); +}); + +test('sampled terrain heights bound the screen box; unknown ones keep it deep', () => { + const square = items('polygon', [ + { + rings: [ + [ + [55, 25], + [55.01, 25], + [55.01, 25.01], + [55, 25.01], + ], + ], + fill: [1, 1, 1, 1], + }, + ]); + const sampled = packSet('polygon', square, { terrainHeight: () => 40 }); + assert.deepEqual([...sampled.heights], [40, 40]); + const unknown = packSet('polygon', square, { + terrainHeight: (lon) => (lon > 55.005 ? undefined : 10), + }); + assert.ok(unknown.heights.every(Number.isNaN)); + assert.ok(packSet('polygon', square).heights.every(Number.isNaN)); +}); + +test('a large area bins its segments; each bin knows its centre', () => { + // A ring of 200 segments round a 10 km circle. + const list = []; + for (let index = 0; index < 200; index++) { + const a = (index / 200) * 2 * Math.PI; + const b = ((index + 1) / 200) * 2 * Math.PI; + list.push( + 5000 * Math.cos(a), + 5000 * Math.sin(a), + 5000 * Math.cos(b), + 5000 * Math.sin(b), + ); + } + assert.equal(binArea(list.slice(0, 4 * BIN_THRESHOLD)), null); + const grid = binArea(list); + assert.equal(grid.columns, grid.rows); + const at = (x, y) => + grid.bins[ + Math.floor((y - grid.y) / grid.size) * grid.columns + + Math.floor((x - grid.x) / grid.size) + ]; + assert.equal(at(0, 0).inside, 1, 'the middle is inside'); + assert.equal(at(0, 0).count, 0, 'and far from every edge'); + assert.equal(at(grid.x + 1, grid.y + 1).inside, 0, 'a corner is outside'); + assert.ok(at(5000, 0).count > 0, 'the rim bins hold its edges'); + // A ring through a pixel's neighbourhood is in its bin's list. + const total = grid.bins.reduce((sum, bin) => sum + bin.count, 0); + assert.ok(total >= 200 && total < 200 * 6, `${total} entries`); +}); + +test('each bin of a packed area points at its own segments', () => { + const ring = Array.from({ length: 80 }, (_, index) => { + const angle = (index / 80) * 2 * Math.PI; + return [55 + 0.2 * Math.cos(angle), 25 + 0.2 * Math.sin(angle)]; + }); + const pack = packSet( + 'polygon', + items('polygon', [{ rings: [ring], fill: [1, 1, 1, 1] }]), + ); + const columns = pack.styles[16]; + const rows = pack.styles[17]; + assert.ok(columns * rows > 1); + assert.equal(pack.bins.length / 4, columns * rows); + let expected = 80; + for (let bin = 0; bin < columns * rows; bin++) { + assert.equal(pack.bins[bin * 4], expected, `bin ${bin} start`); + expected += pack.bins[bin * 4 + 1]; + } + assert.equal(pack.segments.length / 4, expected); +}); diff --git a/src/globe/navara/ground/pass.js b/src/globe/navara/ground/pass.js new file mode 100644 index 000000000..9a5a4203f --- /dev/null +++ b/src/globe/navara/ground/pass.js @@ -0,0 +1,350 @@ +// engine.ground's screen pass on Navara: one public EffectDesc after tone +// mapping, 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), the +// candidates of its screen cell (pack.js) are tested in their own gnomonic +// planes: a disc against its radius, an area against its segments (even-odd +// inside, distance to the nearest edge), a corridor against its distance to +// the line. Fills and outlines are anti-aliased over one pixel across the +// boundary and blended in display space, in draw order, as Cesium blends +// translucent ground primitives. Costs nothing while no item is drawn +// (the effect is hidden). + +import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three'; +import { Effect, EffectAttribute } from 'postprocessing'; +import { + DataTexture, + FloatType, + Matrix4, + NearestFilter, + RGBADepthPacking, + RGBAFormat, + Uniform, +} from 'three'; +import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js'; +import { GRID_COLS, GRID_ROWS, KIND, STYLE_TEXELS, TEXELS } from './pack.js'; + +export const GROUND_EFFECT_KEY = 'gevGround'; + +const fragmentShader = /* glsl */ ` +uniform sampler2D tAllDepth; +uniform sampler2D tSurfaceDepth; +uniform highp sampler2D tFrames; +uniform highp sampler2D tStyles; +uniform highp sampler2D tSegments; +uniform highp sampler2D tBins; +uniform highp sampler2D tGrid; +uniform highp sampler2D tCandidates; +uniform mat4 uInverseProjection; +uniform float uLogDepthFC; +vec4 atSlot(highp sampler2D image, int slot) { + int width = textureSize(image, 0).x; + return texelFetch(image, ivec2(slot % width, slot / width), 0); +} +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)); + float distance = exp2(2.0 * depth / uLogDepthFC) - 1.0; + vec4 ray = uInverseProjection * vec4(uv * 2.0 - 1.0, -1.0, 1.0); + vec3 point = ray.xyz * (distance / -ray.z); + // Derivatives before any divergent branch: neighbours test other items. + vec3 pointDx = dFdx(point); + vec3 pointDy = dFdy(point); + // A quad across the sky has no surface derivative. + float onSurface = 1.0 - step(1.0, depth); + float derivativeMask = 1.0 - step(0.5, abs(dFdx(onSurface)) + abs(dFdy(onSurface))); + pointDx *= derivativeMask; + pointDy *= derivativeMask; + if (depth >= 1.0) return; + ivec2 tile = clamp(ivec2(floor(uv * vec2(${GRID_COLS}.0, ${GRID_ROWS}.0))), ivec2(0), ivec2(${GRID_COLS - 1}, ${GRID_ROWS - 1})); + vec2 range = texelFetch(tGrid, tile, 0).xy; + if (range.y < 0.5) return; + float surface = texture2D(tSurfaceDepth, uv).r; + // The packed depth also holds forward meshes, which are not ground. + if (depth < surface - 2e-7) return; + vec3 color = toDisplay(inputColor.rgb); + int first = int(range.x); + int candidates = int(range.y); + for (int k = 0; k < candidates; k++) { + int id = int(atSlot(tCandidates, first + k).x); + vec4 origin = atSlot(tFrames, id * ${TEXELS}); + vec4 east = atSlot(tFrames, id * ${TEXELS} + 1); + vec4 north = atSlot(tFrames, id * ${TEXELS} + 2); + vec3 up = atSlot(tFrames, id * ${TEXELS} + 3).xyz; + vec4 span = atSlot(tStyles, id * ${STYLE_TEXELS} + 3); + vec3 delta = point - origin.xyz; + float height = dot(delta, up); + if (height < span.x || height > span.y) continue; + vec4 fillColor = atSlot(tStyles, id * ${STYLE_TEXELS}); + vec4 lineColor = atSlot(tStyles, id * ${STYLE_TEXELS} + 1); + vec4 shape = atSlot(tStyles, id * ${STYLE_TEXELS} + 2); + // Along the ray from the Earth's centre onto the item's plane. + float scale = origin.w / (origin.w + height); + vec2 local = vec2(dot(delta, east.xyz), dot(delta, north.xyz)) * scale; + vec2 localDx = vec2(dot(pointDx, east.xyz), dot(pointDx, north.xyz)) * scale; + vec2 localDy = vec2(dot(pointDy, east.xyz), dot(pointDy, north.xyz)) * scale; + // Beyond the item's extent and its padding in pixels: not this item. + float reach = east.w + north.w * (length(localDx) + length(localDy)); + if (dot(local, local) > reach * reach) continue; + int kind = int(shape.x + 0.5); + float nearest = 1e30; + int nearestSegment = 0; + vec2 normal = vec2(1.0, 0.0); + bool inside = false; + if (kind == ${KIND.disc}) { + float r = length(local); + if (r > 0.0) normal = local / r; + nearest = abs(r - shape.w); + inside = r < shape.w; + } else { + int start = int(span.z); + int count = int(span.w); + // A large area: only the segments of the pixel's bin, while its + // reach (half the outline and the anti-aliasing) stays inside the + // half bin the bin's list covers; inside-ness from the bin's centre. + bool binned = false; + bool centreInside = false; + vec2 centre = vec2(0.0); + if (kind == ${KIND.area}) { + vec4 grid = atSlot(tStyles, id * ${STYLE_TEXELS} + 4); + if (grid.x > 0.5 && + (length(localDx) + length(localDy)) * (shape.y * 0.5 + 1.5) <= grid.z * 0.5) { + vec2 origin = atSlot(tStyles, id * ${STYLE_TEXELS} + 5).xy; + ivec2 cell = ivec2(floor((local - origin) / grid.z)); + // Off the grid: outside the area, beyond any edge's reach. + if (cell.x < 0 || cell.y < 0 || cell.x >= int(grid.x) || cell.y >= int(grid.y)) continue; + vec4 bin = atSlot(tBins, int(grid.w) + cell.y * int(grid.x) + cell.x); + start += int(bin.x); + count = int(bin.y); + centreInside = bin.z > 0.5; + centre = origin + (vec2(cell) + 0.5) * grid.z; + binned = true; + } + } + for (int s = 0; s < count; s++) { + vec4 segment = atSlot(tSegments, start + s); + vec2 a = segment.xy; + vec2 ab = segment.zw - a; + vec2 ap = local - a; + float t = clamp(dot(ap, ab) / max(dot(ab, ab), 1e-12), 0.0, 1.0); + vec2 off = ap - ab * t; + float d = length(off); + if (d < nearest) { + nearest = d; + nearestSegment = s; + if (d > 0.0) normal = off / d; + } + if (binned) { + // The segment from the bin's centre to the pixel crosses it. + vec2 cp = local - centre; + float d1 = ab.x * (centre.y - a.y) - ab.y * (centre.x - a.x); + float d2 = ab.x * (local.y - a.y) - ab.y * (local.x - a.x); + float d3 = cp.x * (a.y - centre.y) - cp.y * (a.x - centre.x); + float d4 = cp.x * (segment.w - centre.y) - cp.y * (segment.z - centre.x); + if (d1 * d2 < 0.0 && d3 * d4 < 0.0) inside = !inside; + } else if ((a.y > local.y) != (segment.w > local.y) && + local.x < ab.x * (local.y - a.y) / ab.y + a.x) + inside = !inside; + } + if (binned) inside = inside != centreInside; + } + // Metres one pixel spans across the boundary. + float pixel = max(abs(dot(normal, localDx)) + abs(dot(normal, localDy)), 1e-4); + if (kind == ${KIND.corridorPx} || kind == ${KIND.corridorM}) { + // A chunk of a long corridor draws where its own segments are nearest. + vec2 owned = atSlot(tStyles, id * ${STYLE_TEXELS} + 4).xy; + if (float(nearestSegment) < owned.x - 0.5 || + float(nearestSegment) > owned.x + owned.y - 0.5) continue; + float halfWidth = kind == ${KIND.corridorPx} ? shape.z * 0.5 * pixel : shape.w * 0.5; + float cover = clamp((halfWidth - nearest) / pixel + 0.5, 0.0, 1.0); + color = mix(color, fillColor.rgb, fillColor.a * cover); + continue; + } + float inward = inside ? nearest : -nearest; + if (fillColor.a > 0.0) + color = mix(color, fillColor.rgb, fillColor.a * clamp(inward / pixel + 0.5, 0.0, 1.0)); + if (lineColor.a > 0.0 && shape.y > 0.0) + color = mix(color, lineColor.rgb, lineColor.a * clamp(shape.y * 0.5 - nearest / pixel + 0.5, 0.0, 1.0)); + } + outputColor = vec4(toLinear(color), inputColor.a); +} +`; + +function dataTexture({ data, width, height }) { + const texture = new DataTexture(data, width, height, RGBAFormat, FloatType); + texture.minFilter = NearestFilter; + texture.magFilter = NearestFilter; + texture.needsUpdate = true; + return texture; +} + +/** A composed state (pack.js composeGround) with its GPU textures. */ +function texturesFor(state) { + const textures = { + styles: dataTexture(state.styles), + segments: dataTexture(state.segments), + bins: dataTexture(state.bins), + frames: dataTexture(state.frames), + grid: dataTexture(state.grid), + candidates: dataTexture(state.candidates), + candidateData: state.candidates.data, + dispose() { + for (const key of [ + 'styles', + 'segments', + 'bins', + 'frames', + 'grid', + 'candidates', + ]) + textures[key].dispose(); + }, + }; + return textures; +} + +/** Bytes the pass's data textures hold on the GPU. */ +export const textureBytes = (state) => + state + ? [ + state.styles, + state.segments, + state.bins, + state.frames, + state.grid, + state.candidates, + ].reduce((total, { data }) => total + data.byteLength, 0) + : 0; + +class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) { + static key = GROUND_EFFECT_KEY; + // Navara takes the first anchor that exists: the latest first. + static insertAfter = [ + 'gevFirmsGround', + 'gevEarthquakeGround', + 'gevObservedWeather', + 'gevNepalEvent', + 'gevRecentImagery', + 'toneMapping', + ]; + static insertBefore = [ + 'gevPreSharpenOverlay', + 'gevSharpen', + 'smaa', + 'fxaa', + 'gevOverlay', + 'final', + ]; + + constructor(view, ctx, config) { + super(view, ctx, config); + this.owner = config[GROUND_EFFECT_KEY].owner; + } + + createPass() { + const camera = this.view.camera.raw; + if (!camera) throw new Error('Camera not available for the ground pass'); + this.effect = new Effect('GevGround', fragmentShader, { + // Its own pass: inputColor is the tone-mapped frame. + attributes: EffectAttribute.CONVOLUTION, + uniforms: new Map([ + ['tAllDepth', new Uniform(null)], + ['tSurfaceDepth', new Uniform(null)], + ['tFrames', new Uniform(null)], + ['tStyles', new Uniform(null)], + ['tSegments', new Uniform(null)], + ['tBins', new Uniform(null)], + ['tGrid', new Uniform(null)], + ['tCandidates', new Uniform(null)], + ['uInverseProjection', new Uniform(new Matrix4())], + ['uLogDepthFC', new Uniform(1)], + ]), + }); + return new NavaraEffect(camera, this.effect); + } + + updateFrame() { + const camera = this.view.camera.raw; + const owner = this.owner; + if (!this.effect || !camera || !owner.state) return; + const mrt = this.find('mrt'); + if (mrt?.depthBufferPacking !== RGBADepthPacking) + throw new Error('The ground pass needs packed scene depth'); + const input = this.ctx.getInputBuffer(); + const { state } = owner; + if ( + state.update( + camera.matrixWorldInverse.elements, + camera.projectionMatrix.elements, + input.width, + input.height, + ) + ) { + const textures = owner.textures; + if (textures.candidateData !== state.candidates.data) { + textures.candidates.dispose(); + textures.candidates = dataTexture(state.candidates); + textures.candidateData = state.candidates.data; + } + textures.frames.needsUpdate = true; + textures.grid.needsUpdate = true; + textures.candidates.needsUpdate = true; + } + 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('uLogDepthFC').value = 2 / Math.log2(camera.far + 1); + u.get('tFrames').value = owner.textures.frames; + u.get('tStyles').value = owner.textures.styles; + u.get('tSegments').value = owner.textures.segments; + u.get('tBins').value = owner.textures.bins; + u.get('tGrid').value = owner.textures.grid; + u.get('tCandidates').value = owner.textures.candidates; + } +} + +const registered = new WeakSet(); + +/** + * The pass of one view: set(state) shows a composed state (null: nothing, + * the effect hidden) and disposes the one before. + */ +export function createGroundPass(view) { + if (!registered.has(view)) { + view.registerEffect(GROUND_EFFECT_KEY, GroundEffectDesc); + registered.add(view); + } + const owner = { state: null, textures: null }; + let handle = null; + return { + owner, + set(state) { + const previous = owner.textures; + owner.state = state && state.count ? state : null; + owner.textures = owner.state ? texturesFor(owner.state) : null; + const visible = owner.state !== null; + if (visible && !handle) + handle = view.addEffect({ + [GROUND_EFFECT_KEY]: { owner }, + visible: true, + }); + else handle?.update({ visible }); + previous?.dispose(); + }, + textureBytes: () => textureBytes(owner.state), + destroy() { + handle?.delete(); + handle = null; + owner.textures?.dispose(); + owner.state = null; + owner.textures = null; + }, + }; +} diff --git a/src/globe/navara/ground/vector.js b/src/globe/navara/ground/vector.js new file mode 100644 index 000000000..00848d61d --- /dev/null +++ b/src/globe/navara/ground/vector.js @@ -0,0 +1,147 @@ +// A prototype of engine.ground on Navara's own vector layers, kept to +// measure against the screen pass (pass.js) and not used by the engine: +// each set's items grouped by style into one GeoJSON source and one +// clamped vector layer per group (discs and cells as polygons, pixel +// corridors as polylines; metre corridors are not drawn). `hide` picks how a +// hidden set goes: 'delete' (its layers deleted, rebuilt when shown) or +// 'update' (layer.update with show false). + +import { areaRings, createGroundRegistry } from '../../ground.js'; + +const toHex = (rgba) => + (Math.round(rgba[0] * 255) << 16) | + (Math.round(rgba[1] * 255) << 8) | + Math.round(rgba[2] * 255); + +function ringCoordinates(flat) { + const ring = []; + for (let index = 0; index < flat.length; index += 2) + ring.push([flat[index], flat[index + 1]]); + ring.push(ring[0]); + return ring; +} + +// Features grouped by their layer style: Map. +export function vectorGroups(kind, items) { + const groups = new Map(); + const add = (key, style, feature) => { + if (!groups.has(key)) groups.set(key, { style, features: [] }); + groups.get(key).features.push(feature); + }; + items.forEach((item, index) => { + if (kind === 'corridor') { + if (!item.widthPx) return; + const coordinates = []; + for (let at = 0; at < item.path.length; at += 2) + coordinates.push([item.path[at], item.path[at + 1]]); + add( + `line:${item.fill.join()}:${item.widthPx}`, + { + polyline: { + color: toHex(item.fill), + width: item.widthPx, + opacity: item.fill[3], + transparent: item.fill[3] < 1, + clampToGround: true, + }, + }, + { + type: 'Feature', + id: index, + properties: {}, + geometry: { type: 'LineString', coordinates }, + }, + ); + return; + } + const rings = areaRings(kind, item).map(ringCoordinates); + const fill = item.fill ?? [0, 0, 0, 0]; + const outline = item.outline; + add( + `area:${fill.join()}:${outline ? `${outline.color.join()}:${outline.widthPx}` : ''}`, + { + polygon: { + color: toHex(fill), + opacity: fill[3], + transparent: true, + clampToGround: true, + ...(outline + ? { + outline: true, + outlineShow: true, + outlineColor: toHex(outline.color), + outlineWidth: outline.widthPx, + } + : {}), + }, + }, + { + type: 'Feature', + id: index, + properties: {}, + geometry: { type: 'Polygon', coordinates: rings }, + }, + ); + }); + return groups; +} + +/** + * @param {object} view an initialised Navara ThreeView + * @param {{ hide?: 'delete'|'update' }} [options] + */ +export function createNavaraVectorGround(view, { hide = 'delete' } = {}) { + const registry = createGroundRegistry((options) => { + let groups = new Map(); + let batches = []; + let shown = true; + const release = () => { + for (const { layer, source } of batches) { + layer.delete(); + source.delete(); + } + batches = []; + }; + const build = () => { + release(); + for (const { style, features } of groups.values()) { + const source = view.addSource({ + type: 'geojson', + data: { type: 'FeatureCollection', features }, + }); + const config = { type: 'vector', source, ...style }; + batches.push({ source, config, layer: view.addLayer(config) }); + } + }; + return { + commit(items) { + groups = vectorGroups(options.kind, items); + if (shown || hide === 'update') build(); + if (!shown && hide === 'update') + for (const batch of batches) + batch.layer.update({ ...batch.config, show: false }); + view.forceUpdate(); + }, + show(visible) { + if (visible === shown) return; + shown = visible; + if (hide === 'delete') { + if (visible) build(); + else release(); + } else + for (const batch of batches) + batch.layer.update({ ...batch.config, show: visible }); + view.forceUpdate(); + }, + destroy() { + release(); + view.forceUpdate(); + }, + }; + }); + return { + ground: registry.ground, + pickables: () => [...registry.live.values()], + destroy: () => registry.destroyAll(), + }; +} diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 605f8eec1..1d83a781b 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -34,6 +34,8 @@ import { animeShader } from '../../styles/anime.js'; import { noirShader } from '../../styles/noir.js'; 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 { createNavaraOverlay } from './overlay.js'; import { createPostSampling } from './postSampling.js'; import { @@ -170,6 +172,7 @@ export async function createNavaraGlobe({ container, search, hd = false }) { ), }), lines: createNavaraLines(view, { overlay }), + ground: createNavaraGround(view, { createPass: createGroundPass }), }); const camera = engine.camera; let ground = Number.NaN; diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index c4fed0b70..bef51d157 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -2745,6 +2745,9 @@ export function createContractSuite( }, ], ]; + checks.push( + ...groundChecks(engine, { settle, until, nadirView, readRegion, pointer }), + ); if (mapSources?.photoreal && mapSources.imagery?.length >= 2) checks.push(...mapChecks(engine.maps, mapSources)); if (capabilities.visualStyles) @@ -2757,6 +2760,487 @@ export function createContractSuite( return checks.map(([name, run]) => ({ name, run })); } +// engine.ground: sets of fills on the ground (every engine). +function groundChecks( + engine, + { settle, until, nadirView, readRegion, pointer }, +) { + const { ground } = engine; + const cell = (west, south, east, north, extra = {}) => ({ + west, + south, + east, + north, + ...extra, + }); + const square = (lon, lat, half) => [ + [lon - half, lat - half], + [lon + half, lat - half], + [lon + half, lat + half], + [lon - half, lat + half], + ]; + const screenOf = (lon, lat) => + engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat)); + const throwsType = (run, label) => { + let error = null; + try { + run(); + } catch (caught) { + error = caught; + } + ensure(error instanceof TypeError, `${label} did not throw a TypeError`); + }; + return [ + [ + 'ground: sets are created, committed, listed, shown, hidden and destroyed', + async () => { + throwsType( + () => ground.createSet({ id: 'contract-bad', kind: 'blob' }), + 'an unknown kind', + ); + throwsType( + () => + ground.createSet({ id: 'contract-bad', kind: 'cell', drape: 'x' }), + 'an unknown drape', + ); + const set = ground.createSet({ + id: 'contract-ground', + kind: 'polygon', + order: 3, + }); + const others = []; + try { + let duplicate = null; + try { + ground.createSet({ id: 'contract-ground', kind: 'disc' }); + } catch (error) { + duplicate = error; + } + ensure(duplicate, 'a second live set with the same id'); + ensure(set.id === 'contract-ground', `id ${set.id}`); + ensure( + listed()?.kind === 'polygon' && + listed().count === 0 && + listed().shown === true && + listed().order === 3, + `list() before a commit: ${JSON.stringify(listed())}`, + ); + set.replace([ + { + rings: [square(20, 10, 0.5), square(20, 10, 0.2).reverse()], + fill: [1, 0, 0, 0.5], + outline: { color: [1, 1, 1, 1], widthPx: 2 }, + pickId: 'p1', + }, + { + rings: [[...square(21, 10, 0.3), [20.7, 9.7]]], + fill: [0, 1, 0, 1], + }, + ]); + ensure(set.count() === 0, 'replace() drew before commit()'); + set.commit(); + ensure(set.count() === 2, `count ${set.count()} after commit`); + ensure(listed()?.count === 2, 'list() missed the commit'); + throwsType( + () => + set.replace([ + { + rings: [ + [ + [0, 0], + [1, 1], + ], + ], + }, + ]), + 'a ring of two positions', + ); + throwsType( + () => + set.replace([{ rings: [square(0, 0, 1)], fill: [2, 0, 0, 1] }]), + 'a fill outside 0..1', + ); + set.commit(); + ensure(set.count() === 2, 'a refused replace() changed the set'); + await until(() => set.ready(), 'the polygons drawn'); + set.show(false); + ensure( + set.shown() === false && listed()?.shown === false, + 'show(false) not reported', + ); + set.show(true); + ensure(set.shown() === true, 'show(true) not reported'); + // Every kind takes its own items. + const disc = ground.createSet({ id: 'contract-discs', kind: 'disc' }); + others.push(disc); + disc.replace([ + { + center: [20, 10], + radiusM: 20_000, + fill: [0, 0, 1, 0.4], + outline: { color: [0, 0, 1, 1], widthPx: 3 }, + }, + ]); + disc.commit(); + const cells = ground.createSet({ + id: 'contract-cells', + kind: 'cell', + }); + others.push(cells); + cells.replace([ + cell(179.5, 0, -179.5, 1, { fill: [1, 1, 0, 0.5] }), + cell(20, 10, 20.5, 10.5, { fill: [1, 1, 0, 0.5] }), + ]); + cells.commit(); + const corridors = ground.createSet({ + id: 'contract-corridors', + kind: 'corridor', + }); + others.push(corridors); + corridors.replace([ + { + positions: [ + [19, 9], + [21, 9], + ], + color: [1, 0, 1, 1], + widthPx: 4, + }, + { + positions: [ + [19, 11], + [20, 11.2], + [21, 11], + ], + color: [0, 1, 1, 1], + widthM: 5000, + }, + ]); + corridors.commit(); + throwsType( + () => + corridors.replace([ + { + positions: [ + [0, 0], + [1, 0], + ], + color: [1, 1, 1, 1], + }, + ]), + 'a corridor without a width', + ); + await until( + () => others.every((other) => other.ready()), + 'discs, cells and corridors drawn', + ); + ensure( + ['contract-discs', 'contract-cells', 'contract-corridors'].every( + (id) => ground.list().some((entry) => entry.id === id), + ), + 'list() misses a set', + ); + set.replace([]); + set.commit(); + ensure(set.count() === 0, 'an empty commit kept items'); + } finally { + set.destroy(); + for (const other of others) other.destroy(); + } + ensure(!listed(), 'a destroyed set is still listed'); + set.destroy(); + let refused = null; + try { + set.commit(); + } catch (error) { + refused = error; + } + ensure(refused, 'a destroyed set took a commit'); + function listed() { + return ground.list().find(({ id }) => id === 'contract-ground'); + } + }, + ], + [ + 'ground: picks resolve to the pickId of the item on top', + async () => { + await nadirView(1e6); + const cells = ground.createSet({ + id: 'contract-ground-cells', + kind: 'cell', + pickable: true, + }); + const polygons = ground.createSet({ + id: 'contract-ground-polygons', + kind: 'polygon', + pickable: true, + order: 1, + }); + const quiet = ground.createSet({ + id: 'contract-ground-quiet', + kind: 'disc', + }); + const clicks = []; + const remove = engine.input.on('click', (event) => clicks.push(event), { + pick: true, + }); + try { + cells.replace([ + cell(19, 9.5, 20, 10.5, { fill: [1, 0, 0, 0.6], pickId: 'cell-a' }), + cell(19.2, 9.7, 19.4, 9.9, { fill: [1, 0, 0, 0.6] }), + ]); + cells.commit(); + polygons.replace([ + { + rings: [square(20.6, 10, 0.4)], + fill: [0, 0, 1, 0.6], + pickId: 'poly-b', + }, + ]); + polygons.commit(); + quiet.replace([ + { + center: [20.6, 10], + radiusM: 15_000, + fill: [0, 1, 0, 0.6], + pickId: 'q', + }, + ]); + quiet.commit(); + await until( + () => cells.ready() && polygons.ready() && quiet.ready(), + 'the pickable fills drawn', + ); + await settle(3); + const pickAt = (lon, lat) => engine.picker.pick(screenOf(lon, lat)); + const onCell = pickAt(19.5, 10.2); + ensure( + onCell?.id === 'cell-a' && onCell.owner === 'contract-ground-cells', + `a pick on the cell found ${JSON.stringify(onCell?.id)} of ${onCell?.owner}`, + ); + ensure(onCell.instance === 0, `instance ${onCell.instance}`); + const noId = pickAt(19.3, 9.8); + ensure( + noId?.owner !== 'contract-ground-cells' || noId.id === 'cell-a', + `an item without a pickId was picked (${JSON.stringify(noId?.id)})`, + ); + // Higher order on top, an unpickable set under it never answers. + const onPolygon = pickAt(20.6, 10.25); + ensure( + onPolygon?.id === 'poly-b', + `a pick on the polygon found ${JSON.stringify(onPolygon?.id)}`, + ); + const onQuiet = pickAt(20.6, 10); + ensure( + onQuiet?.id === 'poly-b' && + onQuiet.owner !== 'contract-ground-quiet', + `the unpickable disc answered (${JSON.stringify(onQuiet?.id)} of ${onQuiet?.owner})`, + ); + const outside = pickAt(18, 12); + ensure( + outside === undefined || outside.id === undefined, + `a pick off every fill found ${JSON.stringify(outside?.id)}`, + ); + polygons.show(false); + await settle(3); + const hidden = pickAt(20.6, 10.25); + ensure(hidden?.id !== 'poly-b', 'a hidden set answered a pick'); + polygons.show(true); + await settle(3); + if (!pointer) return; + const before = clicks.length; + const at = screenOf(19.5, 10.2); + await pointer.click(at.x, at.y); + await until(() => clicks.length > before, 'a click payload'); + const clicked = clicks.at(-1); + ensure( + clicked.picked?.id === 'cell-a', + `a click on the cell picked ${JSON.stringify(clicked.picked?.id)}`, + ); + } finally { + remove(); + cells.destroy(); + polygons.destroy(); + quiet.destroy(); + } + }, + ], + [ + 'ground: fills, outlines and corridors draw on the globe (pixels)', + async () => { + if (!readRegion) return; + await nadirView(1e6); + const MAGENTA = [1, 0, 1, 1]; + const GREEN = [0, 1, 0, 1]; + const like = (colour, data, i) => + colour.every( + (channel, k) => + k > 2 || Math.abs(data[i + k] - channel * 255) <= 40, + ); + // Pixels of a colour in a box centred on a ground point. + const countAt = (lon, lat, colour, half = 12) => { + const { x, y } = screenOf(lon, lat); + const data = readRegion( + Math.round(x) - half, + Math.round(y) - half, + 2 * half, + 2 * half, + ); + let count = 0; + for (let i = 0; i < data.length; i += 4) + if (like(colour, data, i)) count++; + return count / (4 * half * half); + }; + const cells = ground.createSet({ + id: 'contract-paint-cells', + kind: 'cell', + }); + const discs = ground.createSet({ + id: 'contract-paint-discs', + kind: 'disc', + order: 1, + }); + const corridors = ground.createSet({ + id: 'contract-paint-corridors', + kind: 'corridor', + order: 2, + }); + const extra = []; + try { + ensure( + countAt(19.5, 10, MAGENTA) < 0.05, + 'the globe is already magenta under the cell', + ); + cells.replace([cell(19, 9.5, 20, 10.5, { fill: MAGENTA })]); + cells.commit(); + discs.replace([ + { + center: [21, 10], + radiusM: 40_000, + fill: GREEN, + outline: { color: MAGENTA, widthPx: 4 }, + }, + ]); + discs.commit(); + corridors.replace([ + { + positions: [ + [19, 8.5], + [21, 8.5], + ], + color: GREEN, + widthPx: 6, + }, + ]); + corridors.commit(); + await until( + () => cells.ready() && discs.ready() && corridors.ready(), + 'the fills built', + ); + await until( + () => countAt(19.5, 10, MAGENTA) > 0.9, + 'the magenta cell drawn', + ); + ensure( + countAt(21, 10, GREEN, 8) > 0.9, + `the disc's centre is ${countAt(21, 10, GREEN, 8).toFixed(2)} green`, + ); + // The outline: magenta on the disc's rim, 40 km east of its centre. + const rimLon = 21 + 40_000 / (111_320 * Math.cos(math.toRadians(10))); + ensure( + countAt(rimLon, 10, MAGENTA, 3) > 0.3, + `the disc's outline is ${countAt(rimLon, 10, MAGENTA, 3).toFixed(2)} magenta at the rim`, + ); + // A 6 px corridor: a column of pixels across it. + const mid = screenOf(20, 8.5); + const column = readRegion( + Math.round(mid.x), + Math.round(mid.y) - 10, + 1, + 20, + ); + let lit = 0; + for (let i = 0; i < column.length; i += 4) + if (like(GREEN, column, i)) lit++; + ensure(lit >= 4 && lit <= 8, `the 6 px corridor is ${lit} px wide`); + cells.show(false); + await until( + () => countAt(19.5, 10, MAGENTA) < 0.05, + 'the hidden cell gone', + ); + cells.show(true); + await until( + () => countAt(19.5, 10, MAGENTA) > 0.9, + 'the cell shown again', + ); + cells.replace([]); + cells.commit(); + await until( + () => countAt(19.5, 10, MAGENTA) < 0.05, + 'the emptied cell gone', + ); + // A polygon of many vertices with a hole (engines may bin it). + const ring = (radius, count) => + Array.from({ length: count }, (_, index) => { + const angle = (index / count) * 2 * Math.PI; + return [ + 18 + radius * Math.cos(angle), + 11 + radius * Math.sin(angle), + ]; + }); + const many = ground.createSet({ + id: 'contract-paint-polygon', + kind: 'polygon', + }); + extra.push(many); + many.replace([ + { rings: [ring(0.7, 240), ring(0.25, 120)], fill: MAGENTA }, + ]); + many.commit(); + await until(() => many.ready(), 'the large polygon built'); + await until( + () => countAt(18.45, 11, MAGENTA, 6) > 0.9, + 'the large polygon drawn', + ); + ensure( + countAt(18, 11, MAGENTA, 6) < 0.05, + `the hole is ${countAt(18, 11, MAGENTA, 6).toFixed(2)} magenta`, + ); + ensure( + countAt(18, 12.2, MAGENTA, 6) < 0.05, + 'the large polygon spills outside its ring', + ); + // Round both rings, just inside and just outside each. + for (let step = 0; step < 12; step++) { + const angle = (step / 12) * 2 * Math.PI + 0.1; + const at = (radius) => [ + 18 + radius * Math.cos(angle), + 11 + radius * Math.sin(angle), + ]; + for (const [radius, filled] of [ + [0.62, true], + [0.78, false], + [0.33, true], + [0.17, false], + ]) { + const share = countAt(...at(radius), MAGENTA, 2); + ensure( + filled ? share > 0.9 : share < 0.1, + `at ${radius}° from the centre, ${(step * 30).toFixed(0)}° round: ${share.toFixed(2)} magenta`, + ); + } + } + } finally { + cells.destroy(); + discs.destroy(); + corridors.destroy(); + for (const set of extra) set.destroy(); + } + }, + ], + ]; +} + // 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 906bcb14a..65fc18bbb 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -49,6 +49,7 @@ import { viewProjection, viewRectangle, } from '../cameraMath.js'; +import { createGroundPicks, createGroundRegistry } from '../ground.js'; import { createLineRegistry } from '../lines.js'; import { createMoverRegistry } from '../movers.js'; import { createCpuPicker } from '../cpuPicks.js'; @@ -257,6 +258,16 @@ export function createFakeEngine(options = {}) { }; return part; }); + // Ground sets keep their items for tests; commit counts as a render request. + const groundRegistry = createGroundRegistry(() => ({ + commit() { + fake.renderRequests++; + }, + show() { + fake.renderRequests++; + }, + destroy() {}, + })); const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()])); const livePosition = cartesian3.create(); let lastBaseImagery = null; @@ -557,8 +568,8 @@ export function createFakeEngine(options = {}) { size: () => ({ width, height }), }; - // Unscripted picks find the fake's own movers and lines, as Navara's CPU - // pick does (src/globe/cpuPicks.js). + // Unscripted picks find the fake's own movers, lines and ground items, as + // Navara's CPU pick does (src/globe/cpuPicks.js, src/globe/ground.js). const cpuPick = createCpuPicker({ projector: { worldToWindow: (position, result) => @@ -583,6 +594,19 @@ export function createFakeEngine(options = {}) { flat: state.positions, modelMatrix: state.modelMatrix, })), + // Ground items under the picked globe point (src/globe/ground.js). + meshes: (at) => groundPicks(at), + }); + const groundPicks = createGroundPicks({ + pickGlobe: (screen) => engine.picker.pickGlobe(screen), + camera: { + get livePosition() { + return engine.camera.livePosition; + }, + pose: () => engine.camera.pose(), + }, + size: () => ({ width, height }), + sets: () => [...groundRegistry.live.values()], }); // camera.follow on the fake's frames (src/globe/follow.js), as Navara. const follow = createFollowController({ @@ -639,6 +663,7 @@ export function createFakeEngine(options = {}) { }, movers: moverRegistry.movers, lines: lineRegistry.lines, + ground: groundRegistry.ground, clock: { now: () => appClock.now(), frameTime: () => appClock.frameTime(), @@ -776,6 +801,7 @@ export function createFakeEngine(options = {}) { cancelCurrentFlight(); moverRegistry.destroyAll(); lineRegistry.destroyAll(); + groundRegistry.destroyAll(); fake.destroyed = true; }, isDestroyed: () => fake.destroyed, diff --git a/src/layers/earthquakes/render/ground.js b/src/layers/earthquakes/render/ground.js new file mode 100644 index 000000000..efef02e20 --- /dev/null +++ b/src/layers/earthquakes/render/ground.js @@ -0,0 +1,69 @@ +// A prototype earthquake renderer on engine.ground, for every engine: each +// disc a ground disc of its radius in the Cesium renderer's fill, picked as +// `earthquake:${stableId}`. Without the outline cesium.js asks for: Cesium +// draws no outline on a ground-clamped ellipse, nor does navara.js, so none +// shows today. Not wired into the layer: scripts/qa-ground-prototypes.mjs +// draws it next to cesium.js to compare. +import * as geo from '../../../geo/index.js'; + +const rgba = (color, alpha) => [color.red, color.green, color.blue, alpha]; + +export function createEarthquakesGroundRenderer(engine) { + const set = engine.ground.createSet({ + id: 'earthquakes', + kind: 'disc', + pickable: true, + }); + set.show(false); + let visible = false; + let rows = []; + let destroyed = false; + return { + kind: 'ground', + setVisible(show) { + if (destroyed) return; + visible = show !== false; + set.show(visible); + }, + replace(discs) { + if (destroyed) return; + const items = []; + const nextRows = []; + for (const disc of discs) { + const place = geo.cartographic.fromCartesian(disc.position); + if (!place) continue; + const lon = geo.math.toDegrees(place.longitude); + const lat = geo.math.toDegrees(place.latitude); + items.push({ + center: [lon, lat], + radiusM: disc.baseRadius, + fill: rgba(disc.color, disc.fillAlpha), + pickId: `earthquake:${disc.stableId}`, + }); + nextRows.push({ + id: disc.usgsId ?? null, + mag: disc.mag, + place: disc.place, + time: disc.time, + depth: disc.depthKm, + lat, + lon, + }); + } + set.replace(items); + set.commit(); + rows = nextRows; + }, + records(limit) { + return !destroyed && visible + ? rows.slice(0, limit).map((row) => ({ ...row })) + : []; + }, + destroy() { + if (destroyed) return; + destroyed = true; + rows = []; + set.destroy(); + }, + }; +} diff --git a/src/layers/perimeters/render/ground.js b/src/layers/perimeters/render/ground.js new file mode 100644 index 000000000..54222ab27 --- /dev/null +++ b/src/layers/perimeters/render/ground.js @@ -0,0 +1,46 @@ +// A prototype fire-perimeter renderer on engine.ground, for every engine: +// each row's polygons (holes kept) as a 0.25 fill under a 2 px outline at +// 0.9 in its containment colour, as the zones draw on Cesium, picked as +// `fire-perimeter:${stableId}:${index}`. Not wired into the layer: +// scripts/qa-ground-prototypes.mjs draws it next to cesium.js to compare. +import { rgbaFromCss } from '../../shared/color.js'; +import { containmentAccent } from '../cards.js'; + +export function createPerimetersGroundRenderer(engine) { + const set = engine.ground.createSet({ + id: 'fire-perimeters', + kind: 'polygon', + pickable: true, + }); + set.show(false); + return { + setVisible(show) { + set.show(show); + }, + replace(rows) { + const items = []; + for (const row of rows) { + const { red, green, blue } = rgbaFromCss( + containmentAccent(row.containedPct), + ); + for (const [index, rings] of row.polygons.entries()) + items.push({ + rings, + fill: [red, green, blue, 0.25], + outline: { color: [red, green, blue, 0.9], widthPx: 2 }, + pickId: `fire-perimeter:${row.stableId}:${index}`, + }); + } + set.replace(items); + set.commit(); + }, + pick(event) { + return event.picked?.owner === 'fire-perimeters' + ? { id: event.picked.id } + : null; + }, + destroy() { + set.destroy(); + }, + }; +}