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(); + }, + }; +}