From 97c4dc76ceb4e346b34f5f6dc0a812556ec5b8cf Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 7 Nov 2026 12:45:21 +0400 Subject: [PATCH] Move scene effects and imported geometry behind the globe contract Move Cesium viewer, map, sky, projection, trail and sonar internals into its engine. Drive styles, bloom, sharpening and contact sonar through engine.render. Count camera-input suspensions across all three engines and migrate the feature callers. Preserve Cesium's effects and input behaviour; fix transit lens clicks on Cesium and make zero sharpening on Navara disable sharpening, as it does on Cesium. Parse imported files into plain records and draw them through movers, lines, ground and imagery on both engines. Follow Cesium's KML style precedence, preserve local KMZ images and frame the drawable records. Open overlays use imagery when the engine has no native map-overlay API. Run every imported-display case on fake and headless Cesium engines. The shared import path retains these known differences from Cesium's old DataSources: wall fills, extruded prisms and gx:LatLonQuad are undrawn; raised polygon fills drape while outlines retain altitude; GroundOverlay rotation, altitude and RGB tint are not applied; rhumb lines become great circles. Ground heights use the loaded surface synchronously instead of waiting for most-detailed sampling. Supported KMZ ground overlays now draw on Navara too. Document the limits rather than claiming full parity. Combine batches B and C because their disjoint path manifests cannot place the shared boundary and format files in a later commit while keeping B independently valid. This combined commit owns the shared docs, package boundaries, format scope, locale catalogs and coupling baseline. Remove five obsolete import strings from all eleven catalogs. Make the load-time source scan include new files and skip deleted paths so the same checks work before and after recording the moves. Coupling: Navara renderer lines fall from 1695 to 1294, Cesium import files from 25 to 9, viewer lines from 1177 to 1026, SDK loads from 4 to 3 and engine-name lines from 62 to 42. No count grows. The full unit suite and the boundary, format and coupling checks pass. Browser validation remains to be run. Browser checks to run GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291 node scripts/qa-contract-suite.mjs http://localhost:4291 node scripts/qa-navara-visual-styles.mjs http://localhost:4291 node scripts/qa-navara-bloom.mjs http://localhost:4291 node scripts/qa-navara-sonar.mjs http://localhost:4291 QA_BASE_URL=http://localhost:4291 node scripts/qa-visual-effects.mjs QA_BASE_URL=http://localhost:4291 node scripts/qa-zoom-out-guard.mjs node scripts/qa-sky-model-lighting.mjs --url http://localhost:4291 node scripts/qa-geo-import.mjs http://localhost:4291 node scripts/qa-geo-import-regressions.mjs http://localhost:4291 node scripts/qa-geo-import-large.mjs http://localhost:4291 node scripts/qa-navara-geo-import.mjs http://localhost:4291 Use the keyless server for every check. Photoreal-only cases remain unverified; do not enable paid tiles to run them. --- docs/CODE-BOUNDARIES.md | 24 +- docs/CURRENT-STATE.md | 48 +- docs/GLOBE-PRIMITIVES.md | 63 ++- package.json | 6 +- scripts/check-cesium-load-time.mjs | 7 +- scripts/engine-coupling-baseline.json | 12 +- scripts/format-scope.json | 41 +- scripts/package-boundaries.json | 121 +++-- scripts/qa-geo-import-large.mjs | 187 +++++--- scripts/qa-geo-import-probe.mjs | 64 +++ scripts/qa-geo-import-regressions.mjs | 156 +++---- scripts/qa-geo-import.mjs | 154 +++--- scripts/qa-navara-bloom.mjs | 8 +- scripts/qa-navara-geo-import.mjs | 10 +- scripts/qa-navara-sonar.mjs | 6 +- scripts/qa-navara-visual-styles.mjs | 6 +- scripts/qa-sky-model-lighting.mjs | 6 +- scripts/qa-visual-effects.mjs | 26 +- scripts/qa-zoom-out-guard.mjs | 5 +- src/app/application.test.mjs | 7 +- src/app/controls.js | 2 +- src/app/controls.test.mjs | 2 +- src/app/data.js | 9 +- src/app/navaraMode.js | 25 +- src/app/navaraToolBackend.test.mjs | 6 +- src/app/scene.js | 7 +- src/app/tools.js | 14 +- src/cameraGlobeRotation.test.mjs | 2 +- src/celestialRing.js | 2 +- src/cyberSonar.js | 22 +- src/cyberSonar.test.mjs | 96 ++++ src/cyberSonarEngine.js | 49 +- src/cyberSonarEngine.test.mjs | 52 +++ src/cyberSonarScene.js | 211 --------- src/cyberSonarScene.test.mjs | 314 ------------- src/data/cameraFraming.js | 5 +- src/data/focusDeemphasis.js | 2 +- src/data/geoFileImport.js | 390 ++-------------- src/data/geoFileImport.test.mjs | 403 +++------------- src/data/geoImportDisplay.js | 438 ++++++++++++++++++ src/data/geoImportDisplay.test.mjs | 307 ++++++++++++ src/data/geoImportKml.js | 410 ++++++++++++++++ src/data/geoImportKml.test.mjs | 378 +++++++++++++++ src/data/geoImportPins.js | 255 +++++----- src/data/geoImportRecords.js | 212 +++++++++ src/data/iconOrientation.js | 2 +- src/data/local_data/sky/README.md | 2 +- src/data/osmOverlays.js | 180 +++---- src/data/osmOverlays.test.mjs | 140 ++---- src/data/trackingClickGesture.js | 2 +- src/data/trailRenderer.js | 2 +- src/{app => globe/cesium}/atmosphereCompat.js | 2 +- .../cesium}/atmosphereCompat.test.mjs | 0 .../cesium.js => globe/cesium/celestial.js} | 0 src/globe/cesium/createCesiumEngine.js | 8 +- src/globe/cesium/createCesiumEngine.test.mjs | 19 +- src/globe/cesium/input.js | 23 +- src/globe/cesium/input.test.mjs | 26 ++ .../imagery.js => globe/cesium/mapImagery.js} | 2 +- .../cesium/mapScene.js} | 6 +- .../cesium/mapScene.test.mjs} | 4 +- src/globe/cesium/maps.js | 9 +- src/globe/cesium/maps.test.mjs | 2 +- src/globe/cesium/postStages.js | 244 ++++++++++ src/globe/cesium/postStages.test.mjs | 149 ++++++ src/globe/cesium/render.js | 69 ++- .../cesium/sceneProjection.js} | 0 .../cesium/screenEvents.js} | 0 src/globe/cesium/sonar.js | 214 +++++++++ src/globe/cesium/sonar.test.mjs | 219 +++++++++ .../cesium/sonarCommands.js} | 7 +- .../cesium/sonarCommands.test.mjs} | 6 +- src/{sky => globe/cesium}/starrySky.js | 22 +- src/{sky => globe/cesium}/starrySky.test.mjs | 0 src/{maps => globe/cesium}/terrain.js | 2 +- .../cesium/trail.js} | 2 +- src/{app => globe/cesium}/viewer.js | 16 +- src/{app => globe/cesium}/zoomOutGuard.js | 2 +- .../cesium}/zoomOutGuard.test.mjs | 0 src/globe/contract.js | 55 ++- src/globe/navara/bloomSettings.js | 2 +- src/globe/navara/input.js | 9 +- src/globe/navara/input.test.mjs | 20 + src/globe/navara/maps.js | 2 +- src/globe/navara/sonar.js | 9 +- src/globe/navara/stylePass.js | 2 +- src/globe/testing/contractSuite.mjs | 52 +++ src/globe/testing/fakeEngine.js | 20 +- src/globe/testing/fakeEngine.test.mjs | 8 + src/i18n/locales/ar.json | 5 - src/i18n/locales/de.json | 5 - src/i18n/locales/es.json | 5 - src/i18n/locales/fr.json | 5 - src/i18n/locales/it.json | 5 - src/i18n/locales/ja.json | 5 - src/i18n/locales/pt.json | 5 - src/i18n/locales/ru.json | 5 - src/i18n/locales/uk.json | 5 - src/i18n/locales/zh-CN.json | 5 - src/i18n/locales/zh-Hant.json | 5 - src/layers/awareness/rangeRing.test.mjs | 4 +- src/layers/awareness/render/display.js | 2 + src/layers/awareness/render/display.test.mjs | 19 + src/layers/cctv/render/display.js | 14 +- src/layers/infrastructure/render/display.js | 3 +- .../infrastructure/render/display.test.mjs | 13 +- src/layers/recentImagery/testDoubles.mjs | 28 +- src/layers/shared/render/navaraStory.js | 401 ---------------- src/layers/shared/render/navaraStory.test.mjs | 192 -------- src/layers/transit/render/display.js | 3 +- src/layers/transit/render/display.test.mjs | 49 ++ src/maps/controller.js | 13 +- src/maps/defaultSources.js | 7 +- src/maps/sourceFactories.test.mjs | 4 +- src/maps/terrainRetry.test.mjs | 2 +- src/testSupport/cctvEngines.mjs | 22 +- src/testSupport/groundDisplayEngines.mjs | 6 +- src/ui/cockpitController.test.mjs | 2 +- src/ui/cockpitHost.js | 10 +- src/ui/displayPanel.test.mjs | 73 ++- src/ui/flyToDataSource.js | 107 +---- src/ui/flyToDataSource.test.mjs | 277 +++++------ src/ui/geoFileImport.js | 153 +++--- src/ui/geoFileImport.test.mjs | 282 ++++++----- src/ui/imageryBoxTool.js | 16 +- src/ui/imageryBoxTool.test.mjs | 8 +- src/ui/shellFacade.js | 8 - src/ui/visualEffects.js | 209 +++------ src/ui/visualEffects.test.mjs | 145 +++--- src/ui/visualSettings.js | 1 - 130 files changed, 4763 insertions(+), 3495 deletions(-) create mode 100644 scripts/qa-geo-import-probe.mjs delete mode 100644 src/cyberSonarScene.js delete mode 100644 src/cyberSonarScene.test.mjs create mode 100644 src/data/geoImportDisplay.js create mode 100644 src/data/geoImportDisplay.test.mjs create mode 100644 src/data/geoImportKml.js create mode 100644 src/data/geoImportKml.test.mjs create mode 100644 src/data/geoImportRecords.js rename src/{app => globe/cesium}/atmosphereCompat.js (99%) rename src/{app => globe/cesium}/atmosphereCompat.test.mjs (100%) rename src/{celestialRing/render/cesium.js => globe/cesium/celestial.js} (100%) rename src/{maps/imagery.js => globe/cesium/mapImagery.js} (98%) rename src/{maps/sceneApply.js => globe/cesium/mapScene.js} (91%) rename src/{maps/sceneApply.test.mjs => globe/cesium/mapScene.test.mjs} (96%) create mode 100644 src/globe/cesium/postStages.js create mode 100644 src/globe/cesium/postStages.test.mjs rename src/{data/render/cesiumProjection.js => globe/cesium/sceneProjection.js} (100%) rename src/{data/render/cesiumConstants.js => globe/cesium/screenEvents.js} (100%) create mode 100644 src/globe/cesium/sonar.js create mode 100644 src/globe/cesium/sonar.test.mjs rename src/{cyberSonarGpu.js => globe/cesium/sonarCommands.js} (97%) rename src/{cyberSonarGpu.test.mjs => globe/cesium/sonarCommands.test.mjs} (99%) rename src/{sky => globe/cesium}/starrySky.js (95%) rename src/{sky => globe/cesium}/starrySky.test.mjs (100%) rename src/{maps => globe/cesium}/terrain.js (97%) rename src/{data/render/cesiumTrailRenderer.js => globe/cesium/trail.js} (98%) rename src/{app => globe/cesium}/viewer.js (96%) rename src/{app => globe/cesium}/zoomOutGuard.js (99%) rename src/{app => globe/cesium}/zoomOutGuard.test.mjs (100%) delete mode 100644 src/layers/shared/render/navaraStory.test.mjs diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index a8475f38d..6614e0062 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -399,10 +399,12 @@ the caller. ## Visual effects `ui/effects` exports the effects controller and existing preset definitions. -It owns shader stages and their clock, with explicit render ownership callbacks. -Construction installs no stages or frame callbacks. Stop animation before -releasing UI consumers, then destroy to remove owned stages and restore the -borrowed bloom state. `ui/effects/bloom` exposes the pure intensity/version helpers +It owns transitions and their clock, with explicit render ownership callbacks, +and writes styles, bloom and sharpening through `engine.render`. Construction +installs no stages or frame callbacks. Stop animation before releasing UI +consumers; controller destruction disables its effects. Cesium's +`src/globe/cesium/postStages.js` owns stage creation and destruction and restores +the borrowed bloom state when the engine is destroyed. `ui/effects/bloom` exposes the pure intensity/version helpers without loading the renderer. UI presentation and product-action coordination remain in their callers. @@ -775,3 +777,17 @@ CSS rather than mapping machine states to universal severity. CCTV owns frame/source and calibration rules and their data attributes, while Weather owns coverage labels. The rail card keeps node identity, header focus, disclosure and teardown, and does not announce every card update. + +## Engine implementation modules + +Cesium's viewer construction, atmosphere compatibility, zoom-out guard, starry +sky, map scene application, imagery and terrain live in `src/globe/cesium/`. +The public viewer export still resolves through `package.json`. Celestial +rendering, screen-event constants, scene projection and the legacy trail +backend also live there. Sonar commands and post stages are engine internals; +feature controllers call `engine.render`. + +Imported-file parsing (`geoFileImport.js`, `geoImportRecords.js`, +`geoImportKml.js`) returns plain records. `geoImportDisplay.js` consumes them +through ground, movers, lines and imagery. The UI owns file bytes, visibility +and attachment lifetime, with no DataSource or engine-specific import backend. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cec55da33..d491375a3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -218,6 +218,12 @@ one application, and only the globe-bound parts move to the next engine. engines, and `scripts/qa-ground-looks.mjs` drew the jam line and the route on Cesium as the old materials did, with no pixel changed at four moments of the pulse and the dashes. +- **Engine-owned effects and camera input.** Cesium's viewer, atmosphere + compatibility, zoom guard, starry sky and map implementation live in + `src/globe/cesium/`. Styles, bloom and contact sonar use `engine.render`. + Camera input suspension is counted on every engine; the last resume restores + the state before the first suspension. Cockpit, imagery-box drawing, CCTV + calibration and infrastructure flights use that shared ownership API. - **Rasters on the ground** (`engine.imagery`) are required of every engine too: layers of Web Mercator tiles from a URL template or of one image (a canvas, an image, RGBA bytes or an image URL) over a rectangle, each with @@ -232,8 +238,9 @@ one application, and only the globe-bound parts move to the next engine. field and the Bhote Koshi event imagery draw through it. Weather and wind require the engine's imagery primitive; the manifest supplies each engine's wind streamlines through the display's flow backend. - - `engine.maps.addOverlay` (Navara's native raster layers for Sea Marks - and Ski Pistes) stays until those overlays move to `engine.imagery`. + - Sea Marks and Ski Pistes use `engine.imagery` on engines without + `maps.addOverlay`; Navara retains its native raster path for tile detail. + Imported KML/KMZ ground images use imagery on both engines. - On November 7, 2026, `scripts/qa-ground-suite.mjs --only=imagery:` (the imagery checks with pixels) passed 2 of 2 on both engines, and `scripts/qa-imagery-compare.mjs` found the heatmap and Recent Imagery @@ -308,8 +315,8 @@ one application, and only the globe-bound parts move to the next engine. restored view and the engine notice. - **Cesium only on demand.** The production build loads `/cesium/Cesium.js` when a Cesium globe starts or a Navara user turns on a feature that still - uses Cesium's maths or entity model (traffic, Street Level, the Bhote Koshi - event and locator, geo import; `src/app/loadCesium.js`). App code does its maths with `src/geo`, a Cesium-free + uses Cesium's maths or entity model (the Bhote Koshi event and locator; + `src/app/loadCesium.js`). Imported files use plain records and load no SDK. App code does its maths with `src/geo`, a Cesium-free port of the vector, ellipsoid, transform and time functions that `bitExact.test.mjs` compares with Cesium bit for bit. **Invariant: no module reads Cesium while it loads** @@ -956,7 +963,7 @@ Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches `terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no proxy in this repository sees those requests (`/api/terrain/heights` is the separate point-height endpoint), and the upstream edge answers zoom-out bursts -with HTTP 429 under load. `src/maps/terrain.js` hands Cesium a `Resource` +with HTTP 429 under load. `src/globe/cesium/terrain.js` hands Cesium a `Resource` whose retry policy (`src/maps/terrainRetry.js`) every derived tile fetch inherits: 429, 502, 503 and 504 replies retry up to three times, 750 ms doubling to a 15 s cap, with every retry waiting behind one shared cooldown @@ -1907,7 +1914,7 @@ provides its confirmed toggle, taxa presets and fixed-order, nonempty legend. ## Sky background -`src/sky/starrySky.js` fills Cesium's `Scene#skyBox` slot with 9,096 Yale +`src/globe/cesium/starrySky.js` fills Cesium's `Scene#skyBox` slot with 9,096 Yale Bright Star Catalogue stars and a soft ESO/S. Brunier Milky Way. Both layers rotate from ICRF/J2000 into the Earth-fixed frame at the scene clock, with a TEME fallback while Cesium's IAU 2006 tables load. Bundled assets and @@ -1953,11 +1960,15 @@ remain unchanged. ## Visual effects ownership -VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom -and sharpen. Display supplies actions and renders settings; effect execution has -no DOM dependency. Existing presets and the 500 ms transition remain unchanged. -Stopping the controller revokes animation before asynchronous UI teardown; final -destruction removes its stages and restores the borrowed bloom configuration. +VisualEffects owns crossfades and animation scheduling and drives styles, bloom +and sharpening through `engine.render` on both engines. Cesium's post stages, +including the Anime Milky Way fade, live in `src/globe/cesium/postStages.js`; +its sonar lives in `sonar.js` and `sonarCommands.js`. Display supplies actions +and renders settings; effect execution has no DOM dependency. Existing presets +and the 500 ms transition remain unchanged. Stopping the controller revokes +animation before asynchronous UI teardown; controller destruction disables the +effects. Engine destruction removes stages and restores borrowed bloom. +Cesium advertises visualStyles, bloom and contactSonar, but not cloudCover. Snow is no longer a visual preset. A share link that names it, in either the long or compact form, opens on Normal and drops the Snow shader parameters. A view passed to embed mode or `show_in_gods_eye_view`, and a saved scene shot, @@ -6229,14 +6240,15 @@ request viewbox and continues searching without that geographic preference. ### Import Your Own Files (October 2026) - UTILITIES ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools. -- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. KML ground lines/rings are tessellated; static recorded tracks preserve their original vertices, segment gaps and explicit altitude modes. KML with absolute/relative altitude keeps that meaning. Ground pins resolve in batches of 256 against loaded terrain and, in the nearby camera footprint on photoreal maps, `clampToHeightMostDetailed`, then draw in one owned native BillboardCollection per file, with static positions and no per-frame point Entity visualizer or clamping callbacks. Point metadata remains in `dataSource.importedEntities` for framing/style inspection; non-point geometry stays in the normal source collection. Pin collections mount only after source admission, follow file show/hide, and are destroyed on removal. Settled camera/map-source changes and completed terrain loads refresh visible files through the same serial queue; distant points retain their latest available height. No detailed worldwide terrain sweep is started. Nearby mesh sampling can add requests/time; configured photoreal providers require separate validation. The depth test is disabled for ground pins, matching Draw's marker visibility policy. -- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rotated overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`. +- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. `geoFileImport.js` and `geoImportKml.js` produce plain records; one `geoImportDisplay.js` draws them on ground, movers, lines and imagery on every engine, without loading Cesium. Ground paths follow great circles and retain segment gaps. Pins use square sprite canvases preserving Cesium's icon placement and styles. Ground and relative pins sample loaded terrain and, at camera heights below 250 km with a tile surface, synchronous `engine.surface.clampToHeight`; absolute pins keep their height. Refreshes run in batches of 256 through the controller's serial queue after camera/map changes and terrain completion, retaining the last available height on misses. Ground pins ignore scene depth and are horizon-culled. Removal destroys every owned drawing set. +- **Drawing limits.** Wall fills, extruded prisms and `gx:LatLonQuad` are counted as undrawn; wall top edges and polygon top geometry remain. Raised polygon fills drape while their outlines retain altitude. Ground-overlay rotation, altitude and RGB tint are not applied; rhumb edges become great circles. These appearance limits are not part of the undrawn count. Packed image overlays draw on both engines. +- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rectangular overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`. - **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged. -- **Input handling.** Coordinates and GeoJSON geometry types are checked before Cesium allocation. Only short plain names reach GeoJSON property bags; file IDs, custom CRS and styling properties are removed. KML feature/geometry IDs are removed while Style/StyleMap references remain. Local style references are trimmed, incomplete style pairs removed, hidden-folder flags normalized to the import's show control, and unsupported prototype-named elements removed. UTF-16 BOMs and common KML namespace export defects are handled. Empty drawable content is refused. A loader failure or timeout does not hold the next file indefinitely; teardown aborts pending waits. -- **References and images.** File-authored remote links/icons/styles, network links, models and screen/photo overlays are removed. Packed KMZ and embedded PNG/JPEG/GIF/WebP/BMP images remain local; decoded dimensions are checked before image decoding against a total 16-megapixel and 8,192-pixel-side budget per file. Blocked IconStyle images fall back to local pins; authored empty icons remain empty. Colored overlays without an Icon survive; overlays with a removed external image are dropped. Metadata, native labels and descriptions are removed, and generated pins share one lossless image URL per canvas. -- **Bounds.** File size ≤50 MiB; inflated KMZ ≤120 MiB; ≤20,000 entities (including multipart children and Folder containers); ≤250,000 positions; ≤2,000 normalized style definitions and distinct image links; ≤12 files per session. Exact repeated styles share definitions without changing inheritance or inline overrides. Files of 20–30 MB fit the byte budget, subject to geometry/image limits. Refusals name a reason and batches continue; with multiple failures the hint shows the first reason. -- **Lifecycle and keyboard.** `createApplicationTools` wires `initGeoFileImport` with viewer, navigation, ground services and map-source subscription. Destroy aborts pending work, unsubscribes, removes data sources, revokes blob URLs, removes document/canvas listeners and releases `window.__gevGeoImport`. List rebuilds retain keyboard focus; removing a row hands focus to the next row, else the previous row, else Import. -- **Validation.** `node scripts/qa-geo-import.mjs` covers all four formats and real painted show/hide. Run `node scripts/qa-geo-import-regressions.mjs` against both dev and preview for XYZ/track heights, failure recovery, namespace/BOM support, portrait framing and panel drops. `node scripts/qa-geo-import-large.mjs [--file /absolute/path/sample.kml] [--expect-local-pins]` checks a 27.6 MB repeated-style file, native style/image equivalence, large-file painted output and frame times. It records the WebGL renderer; software-rendered runs do not establish GPU performance. Unit tests live beside the data/controller/framing modules. +- **Input handling.** Coordinates and GeoJSON geometry types are checked before drawing allocation. Only short plain names reach the record model; file IDs, custom CRS and styling properties are removed. KML feature/geometry IDs are removed while Style/StyleMap references remain. Local style references are trimmed, incomplete style pairs removed, hidden-folder flags normalized to the import's show control, and unsupported prototype-named elements removed. UTF-16 BOMs and common KML namespace export defects are handled. Empty drawable content is refused. A loader failure or timeout does not hold the next file indefinitely; teardown aborts pending waits. +- **References and images.** File-authored remote links/icons/styles, network links, models and screen/photo overlays are removed. Packed KMZ and embedded PNG/JPEG/GIF/WebP/BMP images remain local; decoded dimensions are checked before image decoding against a total 16-megapixel and 8,192-pixel-side budget per file. Blocked IconStyle images fall back to local pins; authored empty icons remain empty. Colored overlays without an Icon survive; overlays with a removed external image are dropped. Metadata, native labels and descriptions are removed, and points with the same icon look share a sprite set and atlas canvas. +- **Bounds.** File size ≤50 MiB; inflated KMZ ≤120 MiB; ≤20,000 drawable records (including multipart children); ≤250,000 positions; ≤2,000 normalized style definitions and distinct image links; ≤12 files per session. Exact repeated styles share definitions without changing inheritance or inline overrides. Files of 20–30 MB fit the byte budget, subject to geometry/image limits. Refusals name a reason and batches continue; with multiple failures the hint shows the first reason. +- **Lifecycle and keyboard.** `createApplicationTools` wires `initGeoFileImport` with the scene engine, navigation, ground services and map-source subscription. Detach destroys drawings and revokes packed-image URLs; reattach reparses the retained file bytes and restores visibility. Destroy aborts pending work, unsubscribes, removes drawings, revokes blob URLs, removes document/canvas listeners and releases `window.__gevGeoImport`. List rebuilds retain keyboard focus; removing a row hands focus to the next row, else the previous row, else Import. +- **Validation.** `node scripts/qa-geo-import.mjs` covers all four formats and real painted show/hide. Run `node scripts/qa-geo-import-regressions.mjs` against both dev and preview for XYZ/track heights, failure recovery, namespace/BOM support, portrait framing and panel drops. `node scripts/qa-geo-import-large.mjs [--file /absolute/path/sample.kml] [--expect-local-pins]` checks a 27.6 MB repeated-style file, effective style equivalence against a native Cesium control, large-file painted output and frame times. It records the WebGL renderer; software-rendered runs do not establish GPU performance. Unit tests live beside the data/controller/framing modules. ### Recent Imagery (September 2026) diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index 6130f616d..e1754b8fb 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -421,12 +421,11 @@ screen pass right after tone mapping, under the other drapes and mip levels come from the derivatives of the ground position, taken before any branch, so rectangle edges and the antimeridian show no seams. -`engine.imagery` is the successor of `engine.maps.addOverlay`, Navara's -undocumented extension for the open map overlays (Sea Marks, Ski Pistes). -That one adds a native Navara raster layer: Navara's own per-tile levels on +Open map overlays (Sea Marks, Ski Pistes) use `engine.imagery` when the +engine has no `maps.addOverlay`. Navara keeps that optional native raster +path for its per-tile detail. It adds a native Navara raster layer: Navara's own per-tile levels on the terrain, lit like the base map, but no rectangle, split, alpha or order, -and nothing on 3D tiles. It stays until those overlays move, which needs a -finer Navara tiles path first: a global overlay seen obliquely gets coarse +and nothing on 3D tiles. Moving that path to imagery needs finer Navara tile detail: a global overlay seen obliquely gets coarse near the camera from one 8×8 atlas over the whole view. ### Who draws through imagery @@ -454,8 +453,58 @@ near the camera from one 8×8 atlas over the whole view. locator's story entities still draw on Cesium itself and through Navara's story backend (`src/layers/shared/render/navaraStory.js`). -`engine.imagery` is still not used by the open map overlays (Sea Marks, Ski -Pistes; see above). +- **Imported KML/KMZ ground images** (`src/data/geoImportDisplay.js`): one + image layer per rectangle at order 30, with the authored alpha capped at + 0.99 as on Cesium. +- **Sea Marks and Ski Pistes** (`src/data/osmOverlays.js`): tile layers at + order 10 on engines without `maps.addOverlay`. + +## Imported files + +`geoFileImport.js` reads GeoJSON, GPX, KML and KMZ into the plain records in +`src/data/geoImportRecords.js`. `geoImportKml.js` resolves KML styles with +Cesium's precedence; its tests compare the records with `KmlDataSource`. +No import loads the Cesium SDK. One `geoImportDisplay.js` draws the records +on every engine: points on movers, ground paths and polygon fills on ground, +raised paths and outlines on lines, and packed ground images on imagery. +Every owned id starts with `geo-import-`; the file controller owns visibility, +framing, refresh and destruction across engine switches. + +Pins use composed square sprites to retain Cesium's pin tip, hot spot, +subregion, colour, scale and rotation. Ground heights come from the loaded +surface, refreshed after the camera or map settles. Ground pins ignore scene +depth and are culled beyond the horizon; absolute pins retain their height. + +The shared display has known limits against Cesium: wall fills and extruded +prisms are omitted (their top geometry remains), and `gx:LatLonQuad` is +omitted. These are counted as undrawn. Raised polygon fills drape; their +outlines retain altitude. Ground-overlay rotation, altitude and RGB tint are +not applied, and ground edges use great circles rather than rhumb lines. +These appearance limits are not included in the undrawn count. + +## Input ownership + +`engine.input.suspendCameraInputs()` disables camera gestures and returns an +idempotent resume function. Suspensions are counted: only the last resume +restores the state captured before the first suspension. This preserves an +already-disabled camera. A `setCameraInputs()` call during suspension updates +the state restored on resume. `cameraInputsEnabled()` reads the current state. +Cockpit, imagery-box drawing, CCTV calibration and infrastructure flights use +these methods on every engine. Pointer claims remain separate from camera +input suspension. + +## Render effects + +The optional `visualStyles`, `bloom` and `contactSonar` capabilities are +available on Cesium and Navara through `engine.render`. Cesium implements +styles, sharpening, borrowed bloom and the Anime Milky Way fade in +`src/globe/cesium/postStages.js`; contact sonar is in `sonar.js` and +`sonarCommands.js`. Cesium does not advertise `cloudCover`. + +`VisualEffects` drives transitions through the render contract. UI teardown +stops animation and disables effects; engine destruction removes its stages +and restores the borrowed bloom state. Sharpen intensity zero disables +sharpening on both engines, following Cesium. ### Choosing a primitive diff --git a/package.json b/package.json index d9479eb1c..6da9ad760 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "./infrastructure/geojson": "./src/data/localGeojsonCore.js", "./infrastructure/lod": "./src/data/localGeojsonLod.js", "./application": "./src/app/application.js", - "./application/viewer": "./src/app/viewer.js", + "./application/viewer": "./src/globe/cesium/viewer.js", "./build/vite": { "node": "./build/vite.js" }, @@ -204,8 +204,8 @@ "./geography/neighborhoods": "./src/data/neighborhoodPolygons.js", "./maps/controller": "./src/maps/controller.js", "./maps/catalog": "./src/maps/catalog.js", - "./maps/imagery": "./src/maps/imagery.js", - "./maps/terrain": "./src/maps/terrain.js", + "./maps/imagery": "./src/globe/cesium/mapImagery.js", + "./maps/terrain": "./src/globe/cesium/terrain.js", "./maps/3d": "./src/maps/google3d.js", "./maps/defaults": "./src/maps/defaultSources.js", "./voice/controller": "./src/voice/realtimeController.js", diff --git a/scripts/check-cesium-load-time.mjs b/scripts/check-cesium-load-time.mjs index 8701475a2..2eb376dbb 100644 --- a/scripts/check-cesium-load-time.mjs +++ b/scripts/check-cesium-load-time.mjs @@ -9,7 +9,7 @@ // // node scripts/check-cesium-load-time.mjs [--json] -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { parse } from '@babel/parser'; @@ -17,7 +17,7 @@ const root = new URL('..', import.meta.url); /** Files under src/ that the app ships (tests and test support excluded). */ export function sourceFiles() { - return execFileSync('git', ['ls-files', 'src'], { + return execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', 'src'], { cwd: root, encoding: 'utf8', }) @@ -27,7 +27,8 @@ export function sourceFiles() { /\.(?:js|mjs)$/.test(path) && !/\.test\.m?js$/.test(path) && !path.startsWith('src/testSupport/') && - !path.includes('/testing/'), + !path.includes('/testing/') && + existsSync(new URL(path, root)), ); } diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index ead4bc184..fa90ecd97 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -1,16 +1,16 @@ { "metrics": { "cesiumLayerRendererLines": 344, - "navaraLayerRendererLines": 1695, - "cesiumImportFiles": 25, - "viewerLines": 1177, + "navaraLayerRendererLines": 1294, + "cesiumImportFiles": 9, + "viewerLines": 1026, "navaraKindChecks": 2, "engineSniffCalls": 0, "engineExtensionReads": 9, "groundEffectPasses": 1, - "cesiumSdkLoads": 4, + "cesiumSdkLoads": 3, "screenSpaceHandlerFiles": 4, - "engineNameLines": 62 + "engineNameLines": 42 }, - "measuredAt": "Remove weather and wind fallback imagery; retain the explicit Cesium wind flow backend seam" + "measuredAt": "Move Cesium internals into the engine and draw imported records through shared primitives" } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 01967f992..fd1b20326 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -56,11 +56,11 @@ "src/app/application.test.mjs", "src/app/mapLifecycle.test.mjs", "src/app/startupCamera.test.mjs", - "src/app/viewer.js", - "src/app/zoomOutGuard.test.mjs", + "src/globe/cesium/viewer.js", + "src/globe/cesium/zoomOutGuard.test.mjs", "src/app/controls.test.mjs", "scripts/qa-zoom-out-guard.mjs", - "src/app/atmosphereCompat.test.mjs", + "src/globe/cesium/atmosphereCompat.test.mjs", "src/standalone/application.js", "src/standalone/controls.js", "src/standalone/data.js", @@ -642,9 +642,9 @@ "src/maps/credits.js", "src/maps/defaultSources.js", "src/maps/google3d.js", - "src/maps/imagery.js", + "src/globe/cesium/mapImagery.js", "src/maps/registry.js", - "src/maps/terrain.js", + "src/globe/cesium/terrain.js", "src/maps/terrainRetry.js", "src/maps/terrainRetry.test.mjs", "src/mapStackController.js", @@ -1100,7 +1100,7 @@ "scripts/build-bright-stars.mjs", "scripts/build-milky-way.mjs", "src/sky/stars.test.mjs", - "src/sky/starrySky.test.mjs", + "src/globe/cesium/starrySky.test.mjs", "src/tooling/skyAssets.test.mjs", "src/tooling/skyAssets.paintOut.test.mjs", "scripts/qa-anime-sky.mjs", @@ -1240,8 +1240,8 @@ "scripts/fixtures/w2/cctv.mjs", "src/layers/radio/screenClusters.js", "src/layers/radio/screenClusters.test.mjs", - "src/maps/sceneApply.js", - "src/maps/sceneApply.test.mjs", + "src/globe/cesium/mapScene.js", + "src/globe/cesium/mapScene.test.mjs", "src/globe/contract.test.mjs", "src/globe/testing/fakeEngine.test.mjs", "src/globe/cesium/events.test.mjs", @@ -1365,7 +1365,7 @@ "src/bench/w1Feeds.js", "src/bench/satelliteFixture.js", "src/bench/satelliteFixture.test.mjs", - "src/celestialRing/render/cesium.js", + "src/globe/cesium/celestial.js", "src/celestialRing/view.js", "src/geo/ellipsoidGeodesic.test.mjs", "src/bench/launchFixture.js", @@ -1374,12 +1374,12 @@ "src/layers/launches/launchPadFrame.test.mjs", "src/layers/satellites/interaction.test.mjs", "src/layers/launches/lifecycleSnapshot.test.mjs", - "src/data/render/cesiumProjection.js", - "src/data/render/cesiumTrailRenderer.js", + "src/globe/cesium/sceneProjection.js", + "src/globe/cesium/trail.js", "src/layers/flights/trackingRefresh.test.mjs", "scripts/qa-heading-navara.mjs", "src/layers/aircraft/qaDrawn.test.mjs", - "src/data/render/cesiumConstants.js", + "src/globe/cesium/screenEvents.js", "src/globe/navara/movers/frameCamera.js", "src/globe/navara/overlayPass.js", "src/globe/navara/overlayPass.test.mjs", @@ -1658,5 +1658,20 @@ "src/globe/navara/ground/corridors.js", "scripts/qa-ground-looks.mjs", "src/layers/wind/render/cesiumFlow.js", - "src/testSupport/imageryDisplayEngines.mjs" + "src/testSupport/imageryDisplayEngines.mjs", + "src/data/geoImportDisplay.js", + "src/data/geoImportDisplay.test.mjs", + "src/data/geoImportKml.js", + "src/data/geoImportKml.test.mjs", + "src/data/geoImportRecords.js", + "src/globe/cesium/atmosphereCompat.js", + "src/globe/cesium/postStages.js", + "src/globe/cesium/postStages.test.mjs", + "src/globe/cesium/sonar.js", + "src/globe/cesium/sonar.test.mjs", + "src/globe/cesium/sonarCommands.js", + "src/globe/cesium/sonarCommands.test.mjs", + "src/globe/cesium/starrySky.js", + "src/globe/cesium/zoomOutGuard.js", + "scripts/qa-geo-import-probe.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9bdadf8c6..930ba108a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -167,15 +167,16 @@ "viewer": { "exports": ["./application/viewer"], "modules": [ - "src/app/viewer.js", - "src/app/atmosphereCompat.js", + "src/globe/cesium/viewer.js", + "src/globe/cesium/atmosphereCompat.js", "src/globeInView.js", "src/geo/", - "src/sky/starrySky.js", + "src/globe/cesium/starrySky.js", "src/sky/stars.js", "src/data/bundledJson.js", "src/renderGovernor.js", - "src/globe/contract.js" + "src/globe/contract.js", + "src/maps/google3d.js" ], "external": ["cesium"] }, @@ -1419,8 +1420,8 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", - "src/data/render/cesiumProjection.js", - "src/data/render/cesiumConstants.js", + "src/globe/cesium/sceneProjection.js", + "src/globe/cesium/screenEvents.js", "src/geo/", "src/time/clock.js", "src/globe/contract.js", @@ -1486,8 +1487,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/data/render/cesiumProjection.js", - "src/data/render/cesiumConstants.js", + "src/globe/cesium/sceneProjection.js", + "src/globe/cesium/screenEvents.js", "src/layers/shared/color.js", "src/geo/", "src/time/clock.js", @@ -1525,8 +1526,8 @@ "src/layers/aircraft/classification.js", "src/layers/aircraft/index.js", "src/data/inputOwnership.js", - "src/data/render/cesiumProjection.js", - "src/data/render/cesiumConstants.js", + "src/globe/cesium/sceneProjection.js", + "src/globe/cesium/screenEvents.js", "src/geo/", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" @@ -1573,7 +1574,7 @@ "src/i18n/locales/zh-Hant.json", "src/layers/vessels/icons.js", "src/layers/shared/color.js", - "src/data/render/cesiumProjection.js", + "src/globe/cesium/sceneProjection.js", "src/geo/", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" @@ -1847,7 +1848,7 @@ "src/data/installationFeedback.js", "src/ui/layerNames.js", "src/ui/unitLabels.js", - "src/data/render/cesiumProjection.js", + "src/globe/cesium/sceneProjection.js", "src/geo/", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" @@ -2247,10 +2248,10 @@ "src/maps/credits.js", "src/maps/defaultSources.js", "src/maps/google3d.js", - "src/maps/imagery.js", + "src/globe/cesium/mapImagery.js", "src/maps/sentinel2.js", "src/maps/registry.js", - "src/maps/terrain.js", + "src/globe/cesium/terrain.js", "src/maps/terrainRetry.js", "src/keySetupCore.mjs", "src/localRequestGate.mjs", @@ -2269,7 +2270,7 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/globe/cesium/credits.js", - "src/maps/sceneApply.js" + "src/globe/cesium/mapScene.js" ], "external": ["cesium"] }, @@ -2389,14 +2390,14 @@ "src/data/headingDegrees.js", "src/data/distanceFormat.js", "src/ui/placeNames.js", - "src/cyberSonarScene.js", + "src/globe/cesium/sonar.js", "src/cyberSonarEngine.js", "src/cyberSonar.js", "src/awarenessEcho.js", "src/hudLayouts.js", "src/hudLayoutPolicy.js", "src/ui/radioPanelPlacement.js", - "src/cyberSonarGpu.js", + "src/globe/cesium/sonarCommands.js", "src/scenes/nepalEvidencePack.js", "src/data/approxDistance.js", "src/data/bhoteKoshiEvent.js", @@ -2442,7 +2443,7 @@ "src/app/operations.js", "src/app/scene.js", "src/app/loadCesium.js", - "src/app/zoomOutGuard.js", + "src/globe/cesium/zoomOutGuard.js", "src/sources/sourceSlot.js", "src/app/sources.js", "src/app/startupChrome.js", @@ -2453,10 +2454,10 @@ "src/app/sessionVoice.js", "src/app/sceneParts.js", "src/app/shellPart.js", - "src/app/viewer.js", - "src/sky/starrySky.js", + "src/globe/cesium/viewer.js", + "src/globe/cesium/starrySky.js", "src/sky/stars.js", - "src/app/atmosphereCompat.js", + "src/globe/cesium/atmosphereCompat.js", "src/bloom.js", "src/camera.js", "src/cameraGroundGuard.js", @@ -2828,10 +2829,10 @@ "src/maps/credits.js", "src/maps/defaultSources.js", "src/maps/google3d.js", - "src/maps/imagery.js", + "src/globe/cesium/mapImagery.js", "src/maps/sentinel2.js", "src/maps/registry.js", - "src/maps/terrain.js", + "src/globe/cesium/terrain.js", "src/maps/terrainRetry.js", "src/navigationPolicy.js", "src/orbit.js", @@ -2880,7 +2881,6 @@ "src/styles/thermal.js", "src/ui/applicationShell.js", "src/ui/mobileShell.js", - "src/exclusiveSurfaces.js", "src/ui/localePicker.js", "src/ui/cyberSonarControls.js", "src/ui/locationNavigation.js", @@ -3326,7 +3326,6 @@ "src/ui/stores/cockpit.js", "src/ui/stores/locale.js", "src/ui/stores/providerSettings.js", - "src/ui/stores/sceneSharing.js", "src/ui/stores/voice.js", "src/ui/stores/location.js", "src/ui/stores/display.js", @@ -3401,15 +3400,15 @@ "src/app/navaraMode.js", "src/annotations/navaraAnnotationRenderer.js", "src/annotations/drawInputNavara.js", - "src/data/render/cesiumProjection.js", - "src/data/render/cesiumConstants.js", - "src/data/render/cesiumTrailRenderer.js", + "src/globe/cesium/sceneProjection.js", + "src/globe/cesium/screenEvents.js", + "src/globe/cesium/trail.js", "src/layers/alpr/overlay.js", "src/layers/alpr/presentation.js", "src/layers/bikeshare/selectionModel.js", "src/layers/bikeshare/presentation.js", "src/layers/vessels/icons.js", - "src/maps/sceneApply.js", + "src/globe/cesium/mapScene.js", "src/layers/localAdsb/qaDrawn.js", "src/geo/", "src/time/clock.js", @@ -3453,7 +3452,7 @@ "src/layers/aircraft/qaDrawn.js", "src/layers/localAdsb/render/display.js", "src/layers/shared/render/engineView.js", - "src/celestialRing/render/cesium.js", + "src/globe/cesium/celestial.js", "src/celestialRing/view.js", "src/layers/shared/render/navaraPolygons.js", "src/layers/awareness/render/navaraPulses.js", @@ -3471,8 +3470,6 @@ "src/layers/shared/render/navaraMoverBank.js", "src/layers/shared/render/moverBank.js", "src/layers/cctv/render/navaraMeshes.js", - "src/layers/alpr/overlay.js", - "src/layers/alpr/presentation.js", "src/layers/radio/screenClusters.js", "src/layers/shared/render/imageAtlas.js", "src/layers/shared/render/navaraGroundSurface.js", @@ -3578,7 +3575,11 @@ "src/globe/cesium/imagery.js", "src/globe/cesium/imageryShell.js", "src/globe/navara/ground/corridors.js", - "src/layers/wind/render/cesiumFlow.js" + "src/layers/wind/render/cesiumFlow.js", + "src/globe/cesium/postStages.js", + "src/data/geoImportRecords.js", + "src/data/geoImportKml.js", + "src/data/geoImportDisplay.js" ], "external": [ "hls.js", @@ -4190,12 +4191,12 @@ "src/globe/cesium/engineFromViewer.js", "src/globe/cesium/createCesiumEngine.js", "src/globe/cesium/maps.js", - "src/maps/terrain.js", + "src/globe/cesium/terrain.js", "src/maps/terrainRetry.js", - "src/maps/sceneApply.js", + "src/globe/cesium/mapScene.js", "src/maps/credits.js", "src/globe/cesium/surface.js", - "src/maps/imagery.js", + "src/globe/cesium/mapImagery.js", "src/maps/sentinel2.js", "src/services/groundSnap.js", "src/maps/google3d.js", @@ -4210,9 +4211,9 @@ "src/globe/cesium/projector.js", "src/globe/cesium/credits.js", "src/globe/contract.js", - "src/data/render/cesiumProjection.js", - "src/data/render/cesiumConstants.js", - "src/data/render/cesiumTrailRenderer.js", + "src/globe/cesium/sceneProjection.js", + "src/globe/cesium/screenEvents.js", + "src/globe/cesium/trail.js", "src/layers/alpr/overlay.js", "src/layers/alpr/presentation.js", "src/layers/bikeshare/selectionModel.js", @@ -4235,7 +4236,7 @@ "src/layers/aircraft/qaDrawn.js", "src/layers/localAdsb/render/display.js", "src/layers/shared/render/moverBank.js", - "src/celestialRing/render/cesium.js", + "src/globe/cesium/celestial.js", "src/celestialRing/view.js", "src/layers/shared/render/engineView.js", "src/layers/firms/rectangle.js", @@ -4326,7 +4327,18 @@ "src/layers/wind/render/flowMesh.js", "src/layers/shared/render/navaraGroundEffect.js", "src/layers/awareness/render/navaraPulses.js", - "src/layers/wind/render/cesiumFlow.js" + "src/layers/wind/render/cesiumFlow.js", + "src/globe/cesium/postStages.js", + "src/globe/cesium/sonar.js", + "src/globe/cesium/sonarCommands.js", + "src/styles/retro.js", + "src/styles/surveillance.js", + "src/styles/thermal.js", + "src/styles/anime.js", + "src/styles/noir.js", + "src/data/geoImportRecords.js", + "src/data/geoImportKml.js", + "src/data/geoImportDisplay.js" ], "external": [ "hls.js", @@ -5054,13 +5066,13 @@ "src/globe/cesium/input.js", "src/maps/google3d.js", "src/services/groundSnap.js", - "src/maps/imagery.js", + "src/globe/cesium/mapImagery.js", "src/maps/sentinel2.js", "src/globe/cesium/surface.js", "src/maps/credits.js", - "src/maps/sceneApply.js", + "src/globe/cesium/mapScene.js", "src/maps/terrainRetry.js", - "src/maps/terrain.js", + "src/globe/cesium/terrain.js", "src/globe/cesium/maps.js", "src/globe/cesium/createCesiumEngine.js", "src/globe/cesium/engineFromViewer.js", @@ -5070,17 +5082,25 @@ "src/globe/contract.js", "src/celestialRing/view.js", "src/layers/shared/render/engineView.js", - "src/data/render/cesiumProjection.js", + "src/globe/cesium/sceneProjection.js", "src/layers/shared/color.js", "src/geo/", "src/time/clock.js", "src/globe/surfaceDetail.js", - "src/celestialRing/render/cesium.js", + "src/globe/cesium/celestial.js", "src/overlays/worldOverlayChrome.js", "src/layers/shared/render/moverBank.js", "src/layers/shared/render/imageAtlas.js", "src/layers/transit/render/style.js", - "src/layers/transit/render/display.js" + "src/layers/transit/render/display.js", + "src/globe/cesium/postStages.js", + "src/globe/cesium/sonar.js", + "src/globe/cesium/sonarCommands.js", + "src/styles/retro.js", + "src/styles/surveillance.js", + "src/styles/thermal.js", + "src/styles/anime.js", + "src/styles/noir.js" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -5310,7 +5330,10 @@ "src/data/geoImportImages.js", "src/data/geoImportPins.js", "src/layers/streetLevel/tileMath.js", - "src/layers/streetLevel/view.js" + "src/layers/streetLevel/view.js", + "src/data/geoImportRecords.js", + "src/data/geoImportKml.js", + "src/data/geoImportDisplay.js" ], "external": ["cesium"] }, @@ -5635,7 +5658,7 @@ "src/layers/wildlifeSightings/sprites.js", "src/layers/wildlifeSightings/photoText.js", "src/layers/wildlifeSightings/performance.js", - "src/data/render/cesiumProjection.js", + "src/globe/cesium/sceneProjection.js", "src/geo/", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js", diff --git a/scripts/qa-geo-import-large.mjs b/scripts/qa-geo-import-large.mjs index 679ea14a1..038ccffe3 100644 --- a/scripts/qa-geo-import-large.mjs +++ b/scripts/qa-geo-import-large.mjs @@ -7,9 +7,13 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { installImportProbe } from './qa-geo-import-probe.mjs'; import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs'; -const base = process.env.QA_BASE_URL || 'http://localhost:4173'; +const base = + process.argv.find((arg) => /^https?:/.test(arg)) || + process.env.QA_BASE_URL || + 'http://localhost:4173'; const supplied = process.argv.includes('--file') ? path.resolve(process.argv[process.argv.indexOf('--file') + 1]) : null; @@ -90,6 +94,7 @@ try { document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 }, ); + await installImportProbe(page); await page.evaluate(async () => { const { viewer, dataManager } = window.__godsEyeView; const C = window.__CESIUM__; @@ -103,49 +108,102 @@ try { destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); + const rgba = (color) => [color.red, color.green, color.blue, color.alpha]; + const xyz = (p) => [p.x, p.y, p.z]; + const lonLat = (p) => { + const c = C.Cartographic.fromCartesian(p); + return [C.Math.toDegrees(c.longitude), C.Math.toDegrees(c.latitude)]; + }; + const canonical = (rows) => + rows + .map((row) => + JSON.stringify(row, (_, value) => + typeof value === 'number' + ? Math.round(value * 1e5) / 1e5 + 0 + : value, + ), + ) + .sort(); window.__qaStyleSnapshot = (source) => { const now = viewer.clock.currentTime; - const images = new Map(); - const imageValue = (p) => { - const value = p?.getValue(now); - if (value?.tagName !== 'CANVAS') return value; - if (!images.has(value)) images.set(value, value.toDataURL()); - return images.get(value); - }; - const color = (p) => p?.getValue(now)?.toCssColorString(); - const positions = (p) => p?.getValue(now)?.map((v) => [v.x, v.y, v.z]); - const renderedPins = new Map(); - const collection = source.importedBillboards; - if (collection) - for (let i = 0; i < collection.length; i++) { - const pin = collection.get(i); - renderedPins.set(pin.id, pin); + const value = (property) => property?.getValue(now); + const rows = []; + for (const e of source.entities.values) { + if (e.billboard && e.position) { + const b = e.billboard, + image = value(b.image); + const scale = value(b.scale) ?? 1; + const width = (value(b.width) ?? image.width) * scale; + const height = (value(b.height) ?? image.height) * scale; + const offset = value(b.pixelOffset) ?? { x: 0, y: 0 }; + const centerY = + offset.y - + (value(b.verticalOrigin) === C.VerticalOrigin.BOTTOM + ? height / 2 + : 0); + rows.push({ + kind: 'pin', + position: xyz(value(e.position)), + size: + 2 * + Math.max( + Math.abs(offset.x) + width / 2, + Math.abs(centerY) + height / 2, + 0.5, + ), + rotation: value(b.rotation) ?? 0, + color: rgba(value(b.color) ?? C.Color.WHITE), + image: Boolean(image), + }); } - const actual = (e, key, fallback) => { - const pin = renderedPins.get(e); - return pin ? pin[key] : (e.billboard?.[key]?.getValue(now) ?? fallback); - }; - return (source.importedEntities || source.entities.values) - .map((e) => ({ - name: e.name, - point: renderedPins.get(e)?.position || e.position?.getValue(now), - scale: e.billboard ? actual(e, 'scale', 1) : undefined, - rotation: e.billboard ? actual(e, 'rotation', 0) : undefined, - image: renderedPins.get(e)?.image || imageValue(e.billboard?.image), - color: e.billboard - ? actual(e, 'color', C.Color.WHITE).toCssColorString() - : undefined, - width: e.polyline?.width?.getValue(now), - lineColor: color(e.polyline?.material?.color), - line: positions(e.polyline?.positions), - fill: e.polygon?.fill?.getValue(now), - outline: e.polygon?.outline?.getValue(now), - polygonColor: color(e.polygon?.material?.color), - polygon: e.polygon?.hierarchy - ?.getValue(now) - ?.positions?.map((v) => [v.x, v.y, v.z]), - })) - .sort((a, b) => a.name.localeCompare(b.name)); + if (e.polyline) + rows.push({ + kind: 'line', + positions: value(e.polyline.positions).map(lonLat), + width: value(e.polyline.width), + color: rgba(value(e.polyline.material.color)), + }); + if (e.polygon && value(e.polygon.fill) !== false) { + const hierarchy = value(e.polygon.hierarchy); + rows.push({ + kind: 'polygon', + rings: [ + hierarchy.positions, + ...hierarchy.holes.map((hole) => hole.positions), + ].map((ring) => ring.map(lonLat)), + fill: rgba(value(e.polygon.material.color)), + }); + } + } + return canonical(rows); + }; + window.__qaDrawnSnapshot = () => { + const rows = []; + for (const r of window.__qaImports.records()) { + if (r.kind === 'movers') + for (const item of r.items) + rows.push({ + kind: 'pin', + position: xyz(item.position), + size: r.options.sprite.sizePx, + rotation: item.rotation, + color: item.color, + image: r.options.sprite.atlas.canvas.width > 0, + }); + if (r.kind === 'ground') + for (const item of r.items) + rows.push( + r.options.kind === 'corridor' + ? { + kind: 'line', + positions: item.positions, + width: item.widthPx, + color: item.color, + } + : { kind: 'polygon', rings: item.rings, fill: item.fill }, + ); + } + return canonical(rows); }; window.__qaUnsharedKml = async (text) => { const doc = new DOMParser().parseFromString(text, 'application/xml'); @@ -170,14 +228,15 @@ try { const outcomes = await window.__gevGeoImport.importFiles([ new File([text], 'style-equivalence.kml'), ]); - const candidate = viewer.dataSources.get(viewer.dataSources.length - 1); + const candidate = window.__gevGeoImport + .list() + .find((file) => file.id === outcomes[0]?.id); const result = { ok: outcomes[0]?.ok, same: JSON.stringify(window.__qaStyleSnapshot(control)) === - JSON.stringify(window.__qaStyleSnapshot(candidate)), - entities: (candidate.importedEntities || candidate.entities.values) - .length, + JSON.stringify(window.__qaDrawnSnapshot()), + entities: candidate?.entityCount, }; await window.__gevGeoImport.remove(outcomes[0]?.id); return result; @@ -251,7 +310,7 @@ try { const loadPicked = async (filename, expected) => { const before = await page.evaluate( - () => window.__godsEyeView.viewer.dataSources.length, + () => window.__qaImports.groups().length, ); const priorIds = await page.evaluate(() => window.__gevGeoImport.list().map((f) => f.id), @@ -276,7 +335,7 @@ try { const state = await page.evaluate(() => ({ files: window.__gevGeoImport.list(), hint: document.getElementById('geo-import-hint').textContent, - sources: window.__godsEyeView.viewer.dataSources.length, + groups: window.__qaImports.groups().length, })); const item = state.files.find((f) => !priorIds.includes(f.id)); check( @@ -285,7 +344,7 @@ try { (expected === null ? item?.entityCount > 0 : item?.entityCount === expected) && - state.sources === before + 1, + state.groups === before + 1, JSON.stringify({ bytes: fs.statSync(filename).size, loadMs: Math.round(performance.now() - started), @@ -297,10 +356,8 @@ try { const item = await loadPicked(file, 5000); if (item) { const same = await page.evaluate(() => { - const viewer = window.__godsEyeView.viewer; - const source = viewer.dataSources.get(viewer.dataSources.length - 1); return ( - JSON.stringify(window.__qaStyleSnapshot(source)) === + JSON.stringify(window.__qaDrawnSnapshot()) === window.__qaUnsharedSnapshot ); }); @@ -338,17 +395,15 @@ try { const markers = await page.evaluate(() => { const { viewer } = window.__godsEyeView, C = window.__CESIUM__; - const source = viewer.dataSources.get(viewer.dataSources.length - 1); - const points = ( - source.importedEntities || source.entities.values - ).filter((e) => e.position && e.billboard); - const images = points.filter((e) => - e.billboard.image?.getValue(viewer.clock.currentTime), - ); + const sets = window.__qaImports + .records() + .filter((record) => record.kind === 'movers'); + const points = sets.flatMap((record) => record.items); + const images = sets + .filter((record) => record.options.sprite.atlas.canvas.width > 0) + .flatMap((record) => record.items); if (images.length) { - const at = C.Cartographic.fromCartesian( - images[0].position.getValue(viewer.clock.currentTime), - ); + const at = C.Cartographic.fromCartesian(images[0].position); viewer.camera.cancelFlight(); viewer.camera.setView({ destination: C.Cartesian3.fromRadians( @@ -436,8 +491,14 @@ try { JSON.stringify(errors), ); check( - 'all imported sources are removed', - await page.evaluate(() => window.__gevGeoImport.list().length === 0), + 'all imported files and drawing sets are removed', + await page.evaluate( + () => + window.__gevGeoImport.list().length === 0 && + Object.values(window.__qaImports.list()).every( + (list) => list.length === 0, + ), + ), ); console.log('Fixture retained at', file); } finally { diff --git a/scripts/qa-geo-import-probe.mjs b/scripts/qa-geo-import-probe.mjs new file mode 100644 index 000000000..e7206423d --- /dev/null +++ b/scripts/qa-geo-import-probe.mjs @@ -0,0 +1,64 @@ +/** Record import writes at the globe contract, including per-instance styles. */ +export async function installImportProbe(page) { + await page.evaluate(() => { + const engine = window.__godsEyeView.engine; + const records = new Map(); + for (const [kind, method] of [ + ['movers', 'createSet'], + ['ground', 'createSet'], + ['lines', 'createLine'], + ['imagery', 'createLayer'], + ]) { + const create = engine[kind][method].bind(engine[kind]); + engine[kind][method] = (options) => { + const handle = create(options); + if (!options.id.startsWith('geo-import-')) return handle; + const record = { kind, options, handle, items: [], positions: [] }; + records.set(options.id, record); + const wrap = (name, after) => { + const original = handle[name].bind(handle); + handle[name] = (...args) => { + const result = original(...args); + after(...args); + return result; + }; + }; + if (kind === 'movers') + wrap('set', (index, fields) => { + record.items[index] = { ...record.items[index], ...fields }; + }); + if (kind === 'ground') + wrap('replace', (items) => { + record.items = items; + }); + if (kind === 'lines') + wrap('setPositions', (positions) => { + record.positions = positions; + }); + wrap('destroy', () => records.delete(options.id)); + return handle; + }; + } + window.__qaImports = { + list: () => + Object.fromEntries( + ['movers', 'ground', 'lines', 'imagery'].map((kind) => [ + kind, + engine[kind] + .list() + .filter(({ id }) => id.startsWith('geo-import-')), + ]), + ), + records: () => [...records.values()], + groups: () => { + const groups = new Map(); + for (const record of records.values()) { + const id = record.options.id.match(/^geo-import-\d+/)[0]; + if (!groups.has(id)) groups.set(id, []); + groups.get(id).push(record); + } + return [...groups.values()]; + }, + }; + }); +} diff --git a/scripts/qa-geo-import-regressions.mjs b/scripts/qa-geo-import-regressions.mjs index ba5a040a2..6d6819ea7 100644 --- a/scripts/qa-geo-import-regressions.mjs +++ b/scripts/qa-geo-import-regressions.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node -/** Import regressions through the real UI controller and Cesium loaders. +/** Import regressions through the real UI controller and globe primitives. * Run against dev and preview: QA_BASE_URL=http://localhost:4173 node scripts/qa-geo-import-regressions.mjs */ import puppeteer from 'puppeteer'; import * as Cesium from 'cesium'; +import { installImportProbe } from './qa-geo-import-probe.mjs'; import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs'; const browser = await puppeteer.launch({ headless: true }); @@ -86,7 +87,7 @@ let failed = 0; try { await page.setViewport({ width: 390, height: 844 }); await page.goto( - `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + `${process.argv.find((arg) => /^https?:/.test(arg)) || process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }, ); await page.waitForFunction( @@ -112,8 +113,9 @@ try { page.on('request', (r) => { if (!/^(data:|blob:)/.test(r.url())) requests.push(r.url()); }); + await installImportProbe(page); const results = await page.evaluate(async () => { - const { viewer } = window.__godsEyeView, + const { viewer, engine } = window.__godsEyeView, C = window.__CESIUM__, tool = window.__gevGeoImport; const results = [], @@ -128,14 +130,27 @@ try { typeof value === 'object' && !ArrayBuffer.isView(value) ? JSON.stringify(value) : value; + const before = new Set(window.__qaImports.records()); const [outcome] = await tool.importFiles([new File([content], name)]); return { outcome, - source: outcome.ok - ? viewer.dataSources.get(viewer.dataSources.length - 1) - : null, + drawing: window.__qaImports + .records() + .filter((record) => !before.has(record)), }; }; + const pins = (r) => + r.drawing + .filter((record) => record.kind === 'movers') + .flatMap((record) => record.items); + const ground = (r) => + r.drawing + .filter((record) => record.kind === 'ground') + .flatMap((record) => record.items); + const lines = (r) => r.drawing.filter((record) => record.kind === 'lines'); + const screenOf = (p) => engine.projector.worldToWindow(p); + const inFrame = (p) => + p && p.x > 0 && p.x < innerWidth && p.y > 0 && p.y < innerHeight; const remove = (r) => { if (r.outcome.ok) tool.remove(r.outcome.id); }; @@ -171,26 +186,20 @@ try { }, 'xyz.geojson', ); - const pin = (r.source?.importedEntities || r.source?.entities.values).find( - (e) => e.billboard, - ), - polygon = (r.source?.importedEntities || r.source?.entities.values).find( - (e) => e.polygon, - ); + const pin = pins(r)[0], + polygon = ground(r)[0]; check( - 'XYZ ground footprint and isolated entity IDs', + 'XYZ ground footprint and isolated pick IDs', r.outcome.ok && - pin.id !== 'kartaview-owned' && - Math.abs( - C.Cartographic.fromCartesian(pin.position.getValue(now)).height, - ) < 0.01 && - polygon.polygon.perPositionHeight?.getValue(now) !== true, + pin.pickId === undefined && + Math.abs(C.Cartographic.fromCartesian(pin.position).height) < 0.01 && + polygon.rings.flat().every((p) => p.length === 2), ); check( - 'ground pins have no persistent clamping callbacks', - pin?.billboard.heightReference?.getValue(now) === - C.HeightReference.NONE && - pin.billboard.disableDepthTestDistance.getValue(now) === Infinity, + 'ground pins use fixed positions and ignore scene depth', + typeof pin.position.x === 'number' && + r.drawing.find((record) => record.kind === 'movers').options + .depthTest === 'always', ); remove(r); r = await load( @@ -201,24 +210,18 @@ try { ), 'tracks.kml', ); - const find = (name) => - (r.source?.importedEntities || r.source?.entities.values).find( - (e) => e.name === name, - ); check( 'ground tracks and untessellated authored lines clamp', - find('ground')?.polyline.clampToGround?.getValue(now) && - find('line')?.polyline.clampToGround?.getValue(now), + ground(r).length === 2, ); check( 'explicit track altitude and original vertices survive', - find('absolute')?.polyline.positions.getValue(now).length === 2 && - find('absolute') - .polyline.positions.getValue(now) - .every( - (p) => - Math.abs(C.Cartographic.fromCartesian(p).height - 10000) < 0.01, - ), + lines(r).length === 1 && + lines(r)[0].positions.length === 2 && + lines(r)[0].positions.every( + (p) => + Math.abs(C.Cartographic.fromCartesian(p).height - 10000) < 0.01, + ), ); remove(r); r = await load( @@ -245,10 +248,7 @@ try { ); check( 'import show controls own previously hidden KML content', - r.outcome.ok && - (r.source.importedEntities || r.source.entities.values).every( - (e) => e.isShowing, - ), + r.outcome.ok && pins(r).length === 1 && pins(r).every((pin) => pin.show), ); remove(r); r = await load( @@ -260,9 +260,8 @@ try { check( 'a colored overlay without an icon is drawable', r.outcome.ok && - (r.source.importedEntities || r.source.entities.values).some( - (e) => e.rectangle, - ), + ground(r).length === 1 && + ground(r)[0].fill.join() === '0,1,0,1', ); remove(r); const xml = wrap(`${point}`); @@ -325,11 +324,10 @@ try { 'portrait.geojson', ); await new Promise((resolve) => setTimeout(resolve, 2300)); - const positions = (r.source?.importedEntities || - r.source?.entities.values)[0].polyline.positions.getValue(now); - const screen = positions?.map((p) => - C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p), + const positions = ground(r)[0].positions.map(([lon, lat]) => + C.Cartesian3.fromDegrees(lon, lat), ); + const screen = positions.map(screenOf); check( 'portrait flight includes both route endpoints', screen?.every( @@ -349,28 +347,13 @@ try { 'intercontinental.geojson', ); await new Promise((resolve) => setTimeout(resolve, 2300)); - const intercontinental = ( - r.source?.importedEntities || r.source?.entities.values - ) - .filter((e) => e.position) - .map((e) => e.position.getValue(now)); - const occluder = new C.EllipsoidalOccluder( - C.Ellipsoid.WGS84, - viewer.camera.positionWC, - ); + const intercontinental = pins(r).map((pin) => pin.position); check( 'intercontinental points remain in frame and in front of the horizon', - intercontinental?.every((p) => { - const s = C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p); - return ( - occluder.isPointVisible(p) && - s && - s.x > 0 && - s.x < innerWidth && - s.y > 0 && - s.y < innerHeight - ); - }), + intercontinental.length === 2 && + intercontinental.every( + (p) => engine.projector.horizonVisible(p) && inFrame(screenOf(p)), + ), ); remove(r); r = await load( @@ -381,20 +364,12 @@ try { ); await new Promise((resolve) => setTimeout(resolve, 2300)); check( - 'a wall contributes both endpoints to the flight', + 'a wall top edge contributes both endpoints to the flight', r.outcome.ok && - (r.source.importedEntities || r.source.entities.values) - .find((e) => e.wall) - ?.wall.positions.getValue(now) - .every((p) => { - const s = C.SceneTransforms.worldToWindowCoordinates( - viewer.scene, - p, - ); - return ( - s && s.x > 0 && s.x < innerWidth && s.y > 0 && s.y < innerHeight - ); - }), + lines(r).length === 1 && + lines(r)[0].positions.length === 2 && + lines(r)[0].positions.every((p) => inFrame(screenOf(p))) && + tool.list().find((file) => file.id === r.outcome.id).undrawn === 1, ); remove(r); r = await load( @@ -447,9 +422,7 @@ try { { type: 'Point', coordinates: [-122.5, 37.7, 0] }, 'mesh-point.geojson', ); - const meshPoint = (r.source?.importedEntities || r.source?.entities.values) - .find((e) => e.billboard) - ?.position.getValue(now); + const meshPoint = pins(r)[0]?.position; const meshHeight = meshPoint && C.Cartographic.fromCartesian(meshPoint).height; check( @@ -463,9 +436,7 @@ try { ); tool.flyTo(r.outcome.id); await new Promise((resolve) => setTimeout(resolve, 2300)); - const meshScreen = - meshPoint && - C.SceneTransforms.worldToWindowCoordinates(viewer.scene, meshPoint); + const meshScreen = meshPoint && screenOf(meshPoint); check( 'a high-surface arrival frames the elevated pin', meshScreen && @@ -480,7 +451,7 @@ try { meshHeight, }, ); - const groundSource = r.source; + const groundDrawing = r; const elevated = await load( wrap( 'absolute-122.5,37.7,5000', @@ -492,9 +463,8 @@ try { 'a higher imported pin never becomes the sampled ground', elevated.outcome.ok && Math.abs( - C.Cartographic.fromCartesian( - groundSource.importedBillboards.get(0).position, - ).height - 2000, + C.Cartographic.fromCartesian(pins(groundDrawing)[0].position).height - + 2000, ) < 2, ); remove(elevated); @@ -526,7 +496,13 @@ try { 'the shared clock stays exactly unchanged', C.JulianDate.equals(now, viewer.clock.currentTime), ); - check('no file sources remain', tool.list().length === 0); + check( + 'no files or drawing sets remain', + tool.list().length === 0 && + Object.values(window.__qaImports.list()).every( + (list) => list.length === 0, + ), + ); return results; }); for (const r of results) { diff --git a/scripts/qa-geo-import.mjs b/scripts/qa-geo-import.mjs index 88a3c4d13..49b1bc3bf 100644 --- a/scripts/qa-geo-import.mjs +++ b/scripts/qa-geo-import.mjs @@ -1,19 +1,19 @@ #!/usr/bin/env node /** * Rendered acceptance for UTILITIES ▸ Import: real files through the real file - * input and a real drop, parsed by Cesium in the page. + * input and a real drop, drawn through the globe contract. * * Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX - * each become one row and one data source; the imported shape actually paints + * each become one row and owned drawing sets; the imported shape actually paints * on the globe in its row's color (a GeoJSON `stroke`/`fill` does not override * it) and stops painting when hidden; no import makes the page request * anything named in it (network link, remote icon, remote style, palette * icon, balloon or extended-data HTML, maki marker) or anything outside the - * page for it; no entity keeps a description, label or properties; dated KML + * page for it; no drawing keeps a description, label or properties; dated KML * and GPX leave the app's clock alone and still draw now; a GPX track keeps * its two segments apart; keyboard focus survives hide/show; a dropped file * imports like a picked one; a file that is not geodata is refused with a - * reason and leaves nothing behind; Remove gives every data source back; no + * reason and leaves nothing behind; Remove releases every drawing set; no * page errors. * * Usage: dev server up (`npm run dev`), then `node scripts/qa-geo-import.mjs`. @@ -23,9 +23,13 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { installImportProbe } from './qa-geo-import-probe.mjs'; import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs'; -const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; +const BASE_URL = + process.argv.find((arg) => /^https?:/.test(arg)) || + process.env.QA_BASE_URL || + 'http://localhost:4173'; const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/geo-import'; const HEADFUL = process.env.QA_HEADFUL === '1'; // Golden Gate Park: a big, flat, green rectangle the test polygon can cover. @@ -233,7 +237,8 @@ async function cyanPixels() { const state = () => page.evaluate(() => ({ files: window.__gevGeoImport.list(), - dataSources: window.__godsEyeView.viewer.dataSources.length, + drawing: window.__qaImports.list(), + groups: window.__qaImports.groups().length, rows: document.querySelectorAll('#geo-import-list .geo-import-row').length, hint: document.getElementById('geo-import-hint')?.textContent || '', })); @@ -283,6 +288,7 @@ try { .width > 0, { timeout: 10_000 }, ); + await installImportProbe(page); const before = await state(); check( 'Import is in the UTILITIES panel with an empty, hidden list', @@ -313,7 +319,6 @@ try { }; }); const clockBefore = await clock(); - const sourcesBefore = before.dataSources; // Picked through the real . const input = await page.$('#geo-import-input'); @@ -328,10 +333,8 @@ try { ); const after = await state(); check( - 'four picked files become four rows and four data sources', - imported && - after.rows === 4 && - after.dataSources === before.dataSources + 4, + 'four picked files become four rows and four groups of drawing sets', + imported && after.rows === 4 && after.groups === before.groups + 4, JSON.stringify( after.files.map((f) => `${f.name}:${f.format}:${f.entityCount}`), ), @@ -361,85 +364,70 @@ try { )), `${JSON.stringify(clockBefore)} → ${JSON.stringify(clockAfter)}`, ); - const inside = await page.evaluate((from) => { - const viewer = window.__godsEyeView.viewer; - const now = viewer.clock.currentTime; - const sources = []; - for (let i = from; i < viewer.dataSources.length; i++) - sources.push(viewer.dataSources.get(i)); - const entities = sources.flatMap( - (ds) => ds.importedEntities || ds.entities.values, - ); - const named = (name) => entities.find((e) => e.name === name); - const gpx = sources[3].importedEntities || sources[3].entities.values; - const kmz = sources[2].importedEntities || sources[2].entities.values; + const inside = await page.evaluate(() => { + const groups = window.__qaImports.groups(); + const pins = (group) => + group.filter((r) => r.kind === 'movers').flatMap((r) => r.items); + const paths = (group) => + group + .filter((r) => r.kind === 'ground' && r.options.kind === 'corridor') + .flatMap((r) => r.items); + const kml = groups[1], + kmz = groups[2], + gpx = groups[3]; + const drawings = groups.flat().flatMap((r) => [r.options, ...r.items]); return { - sources: sources.length, - clocks: sources.filter((ds) => ds.clock !== undefined).length, - described: entities.filter((e) => e.description !== undefined).length, - labelled: entities.filter((e) => e.label !== undefined).length, - withProperties: entities.filter((e) => e.properties !== undefined).length, - withKml: entities.filter((e) => e.kml !== undefined).length, - hidden: entities.filter((e) => !e.isAvailable(now)).length, - datedPinNow: Boolean(named('QA KML pin')?.isAvailable(now)), - fallbackPins: ['QA KML pin', 'QA palette pin'].filter((name) => - named(name)?.billboard?.image?.getValue(now), - ).length, - movingIsLine: Boolean( - named('QA moving')?.polyline && !named('QA moving')?.position, + groups: groups.length, + metadata: drawings.some((r) => + [ + 'description', + 'label', + 'properties', + 'kml', + 'availability', + 'clock', + ].some((key) => key in r), ), - movingVertices: - named('QA moving')?.polyline?.positions?.getValue(now)?.length, - splitTracks: (sources[1].importedEntities || sources[1].entities.values) - .filter((e) => e.parent?.name === 'QA split tracks' && e.polyline) - .map((e) => e.polyline.positions.getValue(now).length), - gpxLines: gpx.filter((e) => e.polyline).length, - gpxPoints: gpx.filter((e) => e.billboard || e.point).length, - kmzLocalImage: kmz.some((e) => - String(e.billboard?.image?.getValue(now) || '').startsWith('blob:'), + fallbackPins: pins(kml).length, + datedPinsShown: pins(kml).every((pin) => pin.show), + kmlVertices: paths(kml).map((line) => line.positions.length), + gpxLines: paths(gpx).length, + gpxPoints: pins(gpx).length, + kmzPins: pins(kmz).length, + kmzOverlay: kmz.some( + (r) => r.kind === 'imagery' && r.options.source.url.startsWith('blob:'), + ), + groundedLines: [kml, kmz].every( + (group) => + paths(group).length === 4 && !group.some((r) => r.kind === 'lines'), + ), + xyzPolygon: groups[0].some( + (r) => + r.kind === 'ground' && + r.options.kind === 'polygon' && + r.items[0].rings.flat().every((p) => p.length === 2), ), - kmzOverlay: kmz.some((e) => e.rectangle), - groundedLines: sources - .slice(1, 3) - .every((ds) => - (ds.importedEntities || ds.entities.values) - .filter((e) => e.polyline) - .every((e) => e.polyline.clampToGround?.getValue(now)), - ), - xyzPolygon: - (sources[0].importedEntities || sources[0].entities.values) - .find((e) => e.polygon) - ?.polygon.perPositionHeight?.getValue(now) !== true, }; - }, sourcesBefore); + }); check( - 'no imported entity keeps a description, label, properties or KML data', - inside.sources === 4 && - inside.described + - inside.labelled + - inside.withProperties + - inside.withKml === - 0, + 'import drawing contains no descriptions, labels, properties or time data', + inside.groups === 4 && !inside.metadata, JSON.stringify(inside), ); check( - 'no imported source has a clock, and dated features draw now', - inside.clocks === 0 && - inside.hidden === 0 && - inside.datedPinNow && - inside.movingIsLine && - inside.movingVertices === 2, + 'dated KML pins draw now and its moving track is a two-vertex line', + inside.datedPinsShown && inside.kmlVertices[0] === 2, JSON.stringify(inside), ); check( - 'blocked KML icon sources use drawable local fallback pins', + 'blocked KML icons use drawable local fallback pins', inside.fallbackPins === 2, - JSON.stringify({ fallbackPins: inside.fallbackPins }), + JSON.stringify(inside), ); check( 'KML MultiTrack keeps its segments and original vertex count', - inside.splitTracks.join() === '2,2', - JSON.stringify(inside.splitTracks), + inside.kmlVertices.slice(1, 3).join() === '2,2', + JSON.stringify(inside), ); check( 'the GPX track keeps its two segments apart', @@ -447,18 +435,13 @@ try { JSON.stringify(inside), ); check( - 'KMZ retains its own packed image and overlay', - inside.kmzLocalImage && inside.kmzOverlay, + 'KMZ retains its packed pin and imagery overlay', + inside.kmzPins === 2 && inside.kmzOverlay && after.files[2].undrawn === 0, ); check( 'XYZ polygons and KML/KMZ lines use ground geometry', inside.xyzPolygon && inside.groundedLines, ); - await page.evaluate((from) => { - for (const entity of window.__godsEyeView.viewer.dataSources.get(from) - .entities.values) - if (entity.billboard) entity.show = false; - }, sourcesBefore); await page.evaluate((view) => { const viewer = window.__godsEyeView.viewer; viewer.camera.cancelFlight?.(); @@ -574,9 +557,10 @@ try { ); const cleared = await state(); check( - 'Remove gives every data source back', - cleared.rows === 0 && cleared.dataSources === before.dataSources, - `${cleared.dataSources} vs ${before.dataSources}`, + 'Remove releases every import drawing set', + cleared.rows === 0 && + Object.values(cleared.drawing).every((list) => list.length === 0), + JSON.stringify(cleared.drawing), ); await wait(1500); check( diff --git a/scripts/qa-navara-bloom.mjs b/scripts/qa-navara-bloom.mjs index e6bc4a06a..ae9ad105c 100644 --- a/scripts/qa-navara-bloom.mjs +++ b/scripts/qa-navara-bloom.mjs @@ -160,7 +160,7 @@ await strict.run({}, async (browser) => { { engine: off.engine, diff: diff(normal, offFrame) }, ); - // Cesium keeps its scene bloom stage; its engine has no bloom. + // Cesium drives its scene bloom stage through the render contract. await strict.switchTo(page, 'cesium'); await freeze(page); await setBloom(page, false, 200); @@ -178,9 +178,9 @@ await strict.run({}, async (browser) => { const cesiumGlow = await capture(page); save('cesium-bloom-200', cesiumGlow); check( - "Cesium: its scene's bloom stage glows, the engine draws none", - cesium.engine === null && - cesiumStage.setBloom === 'undefined' && + "Cesium: the engine drives its scene's bloom stage", + cesium.engine?.enabled === true && + cesiumStage.setBloom === 'function' && cesiumStage.enabled !== false && diff(cesiumNormal, cesiumGlow).mean > 1, { stage: cesiumStage, diff: diff(cesiumNormal, cesiumGlow) }, diff --git a/scripts/qa-navara-geo-import.mjs b/scripts/qa-navara-geo-import.mjs index e1de7cef8..f8029c048 100644 --- a/scripts/qa-navara-geo-import.mjs +++ b/scripts/qa-navara-geo-import.mjs @@ -238,20 +238,18 @@ await strict.run( imported.diagnostics.drawn === 4, { files: imported.files.map((f) => f.format), rows: imported.rows }, ); - // The KMZ's packed ground overlay is a rectangle Navara has no drawing - // for; the KML's remote one was left out of the file on import. + // Packed overlays draw through imagery; remote overlays are removed on import. const undrawn = Object.fromEntries( imported.files.map((f) => [f.name, f.undrawn]), ); await strict.check( page, 'geo-import', - 'the ground overlay Navara cannot draw is counted and reported in the hint', - undrawn['packed.kmz'] === 1 && + 'all supported geometry, including the packed overlay, draws on Navara', + undrawn['packed.kmz'] === 0 && undrawn['park.geojson'] === 0 && undrawn['remote-bits.kml'] === 0 && - undrawn['walk.gpx'] === 0 && - /leaves out of packed\.kmz: 1/.test(imported.hint), + undrawn['walk.gpx'] === 0, { undrawn, hint: imported.hint }, ); await strict.check( diff --git a/scripts/qa-navara-sonar.mjs b/scripts/qa-navara-sonar.mjs index 3c60851d6..6b8aeb376 100644 --- a/scripts/qa-navara-sonar.mjs +++ b/scripts/qa-navara-sonar.mjs @@ -252,7 +252,7 @@ await strict.run({}, async (browser) => { ); await page.evaluate(() => window.__qaSonarSet.destroy()); - // Cesium keeps its command adapter; its engine draws no sonar. + // Cesium draws contact sonar through its engine command adapter. await strict.switchTo(page, 'cesium'); await page.evaluate(() => { const { styleManager } = window.__godsEyeView; @@ -267,8 +267,8 @@ await strict.run({}, async (browser) => { gpu: document.documentElement.dataset.cyberSonarGpu, })); check( - 'Cesium: the command adapter treats contacts, the engine none', - cesium.setContactSonar === 'undefined' && cesium.gpu === 'supported', + 'Cesium: the engine command adapter treats contacts', + cesium.setContactSonar === 'function' && cesium.gpu === 'supported', cesium, ); await page.evaluate(() => diff --git a/scripts/qa-navara-visual-styles.mjs b/scripts/qa-navara-visual-styles.mjs index c60bb36a2..2b6931755 100644 --- a/scripts/qa-navara-visual-styles.mjs +++ b/scripts/qa-navara-visual-styles.mjs @@ -279,7 +279,7 @@ await strict.run({}, async (browser) => { stageName: stage.name, enabled: stage.enabled, intensity: stage.uniforms.intensity, - inScene: a.viewer?.scene?.postProcessStages?.contains?.(stage) ?? null, + inScene: a.engine.render.style('thermal').intensity > 0, }; }); const cesiumThermal = await capture(page); @@ -290,9 +290,9 @@ await strict.run({}, async (browser) => { path: path.join(outDir, 'cesium-thermal-page.png'), }); check( - 'Cesium: FLIR is its own post stage, the engine draws none', + 'Cesium: the engine owns the FLIR post stage', cesium.pending === 0 && - cesiumState.setStyle === 'undefined' && + cesiumState.setStyle === 'function' && cesiumState.enabled === true && cesiumState.intensity === 1 && cesiumState.inScene !== false, diff --git a/scripts/qa-sky-model-lighting.mjs b/scripts/qa-sky-model-lighting.mjs index 2f401506b..69f727297 100644 --- a/scripts/qa-sky-model-lighting.mjs +++ b/scripts/qa-sky-model-lighting.mjs @@ -32,13 +32,15 @@ try { const results = await page.evaluate(async () => { window.CESIUM_BASE_URL = '/cesium/'; // Use StarrySky's exact Vite dependency URL, including its version query. - const source = await fetch('/src/sky/starrySky.js').then((r) => r.text()); + const source = await fetch('/src/globe/cesium/starrySky.js').then((r) => + r.text(), + ); const cesiumUrl = source.match( /from ["']([^"']*\/cesium\.js[^"']*)["']/, )?.[1]; if (!cesiumUrl) throw new Error('run against the Vite dev server'); const C = await import(cesiumUrl); - const { StarrySky } = await import('/src/sky/starrySky.js'); + const { StarrySky } = await import('/src/globe/cesium/starrySky.js'); const time = C.JulianDate.fromIso8601('2026-10-19T00:00:00Z'); const results = {}; for (const kind of ['black', 'starry', 'white']) { diff --git a/scripts/qa-visual-effects.mjs b/scripts/qa-visual-effects.mjs index f3b9ab8bd..43bb607e5 100644 --- a/scripts/qa-visual-effects.mjs +++ b/scripts/qa-visual-effects.mjs @@ -169,7 +169,7 @@ try { result.bloom.intensityPct === 98 && snapshot.bloom.intensity === 98 && manager._visualEffects.bloomIntensity === 98 && - manager._bloomStage.enabled + window.__godsEyeView.viewer.scene.postProcessStages.bloom.enabled ); }), ); @@ -183,7 +183,8 @@ try { result.sharpen.intensityPct === 61 && manager.getVisualState().sharpen.intensity === 61 && manager._visualEffects.sharpenIntensity === 0.61 && - Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9 + Math.abs(window.__godsEyeView.engine.render.sharpen().amount - 1.32) < + 1e-9 ); }), ); @@ -199,9 +200,7 @@ try { const manager = window.__godsEyeView.styleManager; const effects = manager._visualEffects; manager.setStyle('retro'); - const owned = [...Object.values(effects.stages), effects.sharpenStage]; - const collection = manager.viewer.scene.postProcessStages; - const previousBloom = effects.previousBloom; + const engine = window.__godsEyeView.engine; const pending = manager.dispose(); const stoppedBeforeAwait = effects.stopped && @@ -210,22 +209,19 @@ try { await pending; return { stoppedBeforeAwait, - removed: owned.every((stage) => !collection.contains(stage)), - restored: - effects.bloomStage.enabled === previousBloom.enabled && - Object.entries(previousBloom.uniforms).every( - ([name, value]) => effects.bloomStage.uniforms[name] === value, - ), + disabled: + engine.render.visualStyles.every( + (name) => engine.render.style(name).intensity === 0, + ) && + !engine.render.bloom().enabled && + !engine.render.sharpen().enabled, }; }); check( 'UI teardown stops effect animation before its first await', teardown.stoppedBeforeAwait, ); - check( - 'UI teardown removes owned stages and restores borrowed bloom', - teardown.removed && teardown.restored, - ); + check('UI teardown disables owned effects on the engine', teardown.disabled); check( 'effects do not introduce uncaught browser errors', errors.length === 0, diff --git a/scripts/qa-zoom-out-guard.mjs b/scripts/qa-zoom-out-guard.mjs index f6a4aec84..1cbea6754 100644 --- a/scripts/qa-zoom-out-guard.mjs +++ b/scripts/qa-zoom-out-guard.mjs @@ -36,7 +36,8 @@ try { ); const renderer = await page.evaluate(async () => { const C = await import('/node_modules/cesium/Build/Cesium/index.js'); - const { maxPitchForHeight } = await import('/src/app/zoomOutGuard.js'); + const { maxPitchForHeight } = + await import('/src/globe/cesium/zoomOutGuard.js'); const { readCameraTargetFrame } = await import('/src/ui/cameraOrientationControls.js'); const { viewer, engine } = window.__godsEyeView; @@ -86,7 +87,7 @@ try { }; viewer.camera.setView = (options) => { // Cesium itself also calls setView while processing touch movement. - if (new Error().stack.includes('/src/app/zoomOutGuard.js')) + if (new Error().stack.includes('/src/globe/cesium/zoomOutGuard.js')) qa.guardWrites++; return original(options); }; diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index c67baec30..e50935691 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -9,7 +9,7 @@ import { installFreeLookHeightLimit, installGlobeInViewRecovery, installTrackpadPinchZoom, -} from './viewer.js'; +} from '../globe/cesium/viewer.js'; import * as globeInView from '../globeInView.js'; const { FREE_LOOK_MAX_HEIGHT_METERS } = globeInView; @@ -300,8 +300,9 @@ test('the separate viewer export imports without constructing a browser viewer', }); test('the viewer gets the starry sky as an option, which keeps the Sun and Moon', async () => { - const { applicationViewerOptions } = await import('./viewer.js'); - const { StarrySky } = await import('../sky/starrySky.js'); + const { applicationViewerOptions } = + await import('../globe/cesium/viewer.js'); + const { StarrySky } = await import('../globe/cesium/starrySky.js'); const creditContainer = {}; const options = applicationViewerOptions(creditContainer); // CesiumWidget skips scene.sun and scene.moon when skyBox is false. diff --git a/src/app/controls.js b/src/app/controls.js index 4f3ccf587..711583eda 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -1,7 +1,7 @@ import { catalogControlServices } from './catalog.js'; import { StyleManager } from '../ui/composition.js'; import { flyToAustin } from '../camera.js'; -import { installZoomOutGuard } from './zoomOutGuard.js'; +import { installZoomOutGuard } from '../globe/cesium/zoomOutGuard.js'; import { initCockpitCloudEffects, initEngineCockpitClouds, diff --git a/src/app/controls.test.mjs b/src/app/controls.test.mjs index 3fa26a5d2..ba4baf1f8 100644 --- a/src/app/controls.test.mjs +++ b/src/app/controls.test.mjs @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'; import * as Cesium from 'cesium'; import '../testSupport/stubMgrs.mjs'; import { installVueDom } from '../testSupport/vueDom.mjs'; -import { MAXIMUM_ZOOM_DISTANCE_M } from './zoomOutGuard.js'; +import { MAXIMUM_ZOOM_DISTANCE_M } from '../globe/cesium/zoomOutGuard.js'; const { createApplicationControls } = await import('./controls.js'); diff --git a/src/app/data.js b/src/app/data.js index 9be26fca8..bab002c61 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -1,6 +1,5 @@ import { LayerLifecycle } from '../data/lifecycle.js'; import { LayerPresentation } from './layerPresentation.js'; -import { createCyberSonarScene } from '../cyberSonarScene.js'; import { createCyberSonarEngineScene } from '../cyberSonarEngine.js'; import { panelAwareVisibility } from './embed.js'; import { attachScenePart, currentSceneOf } from './sceneParts.js'; @@ -105,11 +104,9 @@ export function createApplicationData({ nextCatalog.weatherClock, ); styleManager.attachDataManager(dataManager); - // The sonar treats contacts on the viewer, or through an engine - // that draws it (render.setContactSonar, Navara). - if (next.viewer) - release(createCyberSonarScene(next.viewer, dataManager)); - else release(createCyberSonarEngineScene(next.engine, dataManager)); + // The engine draws the sonar's contact treatment + // (render.setContactSonar). + release(createCyberSonarEngineScene(next.engine, dataManager)); if (!binding) return; // A teardown or switch during the attachment must not wait for slow // layer attachments: it ends this wait, and the detach releases the diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 32450aa8f..cd7020616 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -15,18 +15,6 @@ import { import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js'; import { navaraDrawSurface } from '../annotations/drawInputNavara.js'; -/** - * An imported file's data source drawn on `engine` with Navara's `views` - * (UTILITIES ▸ Import); the drawing loads on first use, like the layers' - * Navara renderers. - */ -async function drawImported(engine, views, dataSource) { - const { drawImportedSource, measureImportImages } = - await import('../layers/shared/render/navaraStory.js'); - const sizes = await measureImportImages(dataSource); - return drawImportedSource(engine, views, dataSource, { sizes }); -} - /** The Navara globe module, loaded on demand. */ const loadNavaraGlobeModule = async () => (await import('../globe/navara/index.js')).createNavaraGlobe; @@ -165,8 +153,8 @@ export async function createNavaraScene({ /** * The Navara tool backend (src/app/tools.js) of one Navara scene: where the - * annotation board, Draw and Import draw (on the engine and Navara's view; - * null without the view), the Recent Imagery panel's globe, the render + * annotation board and Draw draw (on the engine and Navara's view; null + * without the view), the Recent Imagery panel's globe, the render * governor on the engine, and this engine's own parts: the daylight of a * restored view and the engine notice. */ @@ -181,15 +169,6 @@ export function navaraToolBackend({ engine }) { // The session's tool on the engine's input and preview // (src/annotations/drawInputNavara.js). draw: () => ({ surface: navaraDrawSurface(engine) }), - // The session's files drawn by src/layers/shared/render/navaraStory.js; - // what Navara leaves out of them is reported in the control's hint. - geoImport: () => - drawn - ? { - engine, - draw: (dataSource) => drawImported(engine, views, dataSource), - } - : null, recentImagery: () => ({ engine }), // Holds drive engine.render.setContinuous; requests go to the engine. A // parked engine uninstalls it (createNavaraScene suspend). diff --git a/src/app/navaraToolBackend.test.mjs b/src/app/navaraToolBackend.test.mjs index af8b94be5..a178dc215 100644 --- a/src/app/navaraToolBackend.test.mjs +++ b/src/app/navaraToolBackend.test.mjs @@ -89,12 +89,12 @@ test('Navara draws the page session board and its switch takes only the drawing' second.detach(); lifetime.abort(); assert.equal(window.__gevAnnotations, undefined); - // Without the Navara view there is nothing to draw on: no board, no - // Import. + // Without the Navara view there is nothing to draw on: no board. Import + // draws through the engine contract, outside the backend. const viewless = navaraToolBackend({ engine: createFakeEngine({ kind: 'navara' }), }); assert.equal(viewless.board(), null); - assert.equal(viewless.geoImport(), null); + assert.equal('geoImport' in viewless, false); assert.deepEqual(Object.keys(viewless.recentImagery()), ['engine']); }); diff --git a/src/app/scene.js b/src/app/scene.js index e585c3fb1..90f83dd73 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -1,15 +1,14 @@ import { releaseCameraVerbs } from '../cameraVerbs.js'; import { createApplicationOperations } from './operations.js'; -import * as Cesium from 'cesium'; import { createApplicationViewer, installFreeLookHeightLimit, installTrackpadPinchZoom, -} from '../app/viewer.js'; + loadCesiumPhotorealTileset, +} from '../globe/cesium/viewer.js'; import { registerDataCredits } from '../data/dataCredits.js'; import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; import { MapStackController } from '../mapStackController.js'; -import { loadPhotorealisticTileset } from '../mapStartup.js'; import { initLogoGaze } from '../logoGaze.js'; import { uninstallRenderGovernor, @@ -174,7 +173,7 @@ async function createRenderer({ googleApiKey || googleTokens || cesiumToken ? t('Loading Google 3D Tiles...') : t('Loading the keyless globe...'); - const photoreal = await loadPhotorealisticTileset(Cesium, { + const photoreal = await loadCesiumPhotorealTileset({ googleApiKey, googleTokens, cesiumToken, diff --git a/src/app/tools.js b/src/app/tools.js index 092a2f99e..714bdec31 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -19,7 +19,7 @@ import { isScopeMaskEnabled, } from '../scopeMask.js'; import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; -import { installGlobeInViewRecovery } from './viewer.js'; +import { installGlobeInViewRecovery } from '../globe/cesium/viewer.js'; import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; import { installRenderGovernor, @@ -272,7 +272,6 @@ function cesiumToolBackend({ engine, tileset }) { viewer, board: () => ({ viewer, engine, tileset }), draw: () => ({ viewer, engine }), - geoImport: () => ({ viewer, engine }), // The tileset lets the layer drape while the globe is hidden. recentImagery(layer) { layer.attachTileset(tileset); @@ -336,8 +335,8 @@ function cesiumToolBackend({ engine, tileset }) { * Import, the Recent Imagery box and panel, the render governor, the scope * mask, hidden-tab handling and voice, and the engine's own tools. The * engine kind's backend (TOOL_BACKENDS) says where the shared tools draw: - * `board()`, `draw()` and `geoImport()` give their attachScene arguments - * (null: not on this engine), `recentImagery(layer)` the panel's globe, + * `board()` and `draw()` give their attachScene arguments (null: not on + * this engine), `recentImagery(layer)` the panel's globe, * `installGovernor()` the governor's; `attach(context, attachment)` adds * the engine's own tools, `suspend(hidden, controls)` its hidden-tab * handling and `attachViews(context, attachment)` its embedded views. The @@ -369,8 +368,9 @@ async function attachEngineTools( defer, ); attachDraw(backend.draw, { layerSession, annotations, styleManager }, defer); + // Import draws through the globe contract, the same on every engine. attachGeoImport( - backend.geoImport(), + { engine }, { layerSession, styleManager, operations, mapStackController }, defer, ); @@ -513,8 +513,8 @@ function attachDraw( } /** - * UTILITIES ▸ Import on this globe (`importScene`, null: not on it): the - * next globe reads the session's files again. The drop listeners belong to + * UTILITIES ▸ Import on this globe (`importScene`: its engine): the next + * globe reads the session's files again. The drop listeners belong to * this globe. Returns the control (null on a page without it). */ function attachGeoImport( diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index 3898c0eba..e2d461f31 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -20,7 +20,7 @@ import { globeFrameShare as poseFrameShare, globeOutOfFrame as poseOutOfFrame, } from './globeInView.js'; -import { cesiumFramePose } from './app/viewer.js'; +import { cesiumFramePose } from './globe/cesium/viewer.js'; import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js'; import { setScopeMaskEnabled } from './scopeMask.js'; diff --git a/src/celestialRing.js b/src/celestialRing.js index ebf8a4315..b5db3eaf9 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -4,7 +4,7 @@ import { cesiumIcrfToFixed, projectCelestialPoint, flyCelestialViewer, -} from './celestialRing/render/cesium.js'; +} from './globe/cesium/celestial.js'; import { createCelestialEphemeris } from './celestialRing/ephemeris.js'; import { appClock } from './time/clock.js'; import { governorRequestRender } from './renderGovernor.js'; diff --git a/src/cyberSonar.js b/src/cyberSonar.js index e4148a43c..1fb6f5e59 100644 --- a/src/cyberSonar.js +++ b/src/cyberSonar.js @@ -163,11 +163,29 @@ export function createCyberSonarSampler( root = globalThis.document?.documentElement, ) { if (now === undefined) now = globalThis.performance?.now?.() ?? 0; - const settings = readCyberSonarSettings(root); + const { range, opacity, sector } = readCyberSonarSettings(root); + return cyberSonarSampler(width, height, { + angleDeg: cyberSonarAngleDeg(now), + range, + opacity, + sector, + }); +} + +/** + * The sampler of one paint from a sonar state (the globe contract's + * ContactSonar: the sweep's angle and the range, opacity and sector + * settings), as an engine that is handed the state samples it. + */ +export function cyberSonarSampler( + width, + height, + { angleDeg: angle, range, opacity, sector }, +) { + const settings = { range, opacity, sector }; const floor = settings.opacity / 100; const radius = (Math.min(width * 0.4, height * 0.52) * settings.range) / 100; const radiusSquared = radius * radius; - const angle = cyberSonarAngleDeg(now); const feather = Math.min(CYBER_SONAR_FEATHER_DEG, settings.sector * 0.42); return { floor, diff --git a/src/cyberSonar.test.mjs b/src/cyberSonar.test.mjs index 50d0338ca..9f7531eee 100644 --- a/src/cyberSonar.test.mjs +++ b/src/cyberSonar.test.mjs @@ -2,8 +2,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { readFile } from 'node:fs/promises'; import { transformWithEsbuild } from 'vite'; +import * as Cesium from 'cesium'; import { BillboardCollection, Color, PointPrimitive } from 'cesium'; import { + createCyberSonarSampler, + cyberSonarSampler, + isCyberContactSonarActive, syncCyberSonarAnimation, applyCyberSonarPrimitive, cyberSonarBaseAlpha, @@ -368,3 +372,95 @@ test('the selected Cyber theme seeds and releases the compositor sweep phase', ( assert.equal(values.has('--cyber-sonar-angle'), false); assert.equal(values.has('--cyber-sonar-phase-delay'), false); }); + +test('frame sampler matches reference without per-contact settings access', () => { + let reads = 0; + const values = { + cyberSonarOpacity: '62', + cyberSonarSector: '40', + cyberSonarRange: '110', + }; + const root = { + dataset: new Proxy(values, { + get(o, k) { + reads++; + return o[k]; + }, + }), + }; + const frame = createCyberSonarSampler(1000, 800, 1234, root), + once = reads; + for (let x = 0; x < 1000; x += 33) + for (let y = 0; y < 800; y += 31) frame.at(x, y); + assert.equal(reads, once); + for (let x = 0; x < 1000; x += 33) + for (let y = 0; y < 800; y += 31) + assert.ok( + Math.abs( + frame.at(x, y) - + cyberSonarIntensityAtScreenPoint(x, y, 1000, 800, 1234, { + dataset: values, + }), + ) < 1e-10, + ); +}); + +test('a sampler from a sonar state matches the one from the settings it carries', () => { + const root = { + dataset: { + cyberSonarOpacity: '62', + cyberSonarSector: '40', + cyberSonarRange: '110', + }, + }; + const fromSettings = createCyberSonarSampler(1000, 800, 1234, root); + const fromState = cyberSonarSampler(1000, 800, { + angleDeg: cyberSonarAngleDeg(1234), + range: 110, + opacity: 62, + sector: 40, + }); + assert.equal(fromState.floor, fromSettings.floor); + assert.equal(fromState.neutral, fromSettings.neutral); + for (let x = 0; x < 1000; x += 37) + for (let y = 0; y < 800; y += 29) + assert.equal(fromState.at(x, y), fromSettings.at(x, y)); +}); + +test('models retain render-pass alpha and owner refresh survives teardown', () => { + const frame = createCyberSonarSampler(1000, 800, 0); + const model = { color: new Cesium.Color(1, 0.5, 0.2, 1) }; + applyCyberSonarPrimitive(model, 0.5, true, frame, 'color', true); + assert.equal(model.color.alpha, 1); + assert.ok(model.color.red < 1); + const owner = new Cesium.Color(0.2, 0.3, 0.4, 0.7); + model.color = owner; + applyCyberSonarPrimitive(model, 1, false); + assert.equal(model.color, owner); + const unset = {}; + applyCyberSonarPrimitive(unset, 0.5, true, frame, 'color', true); + applyCyberSonarPrimitive(unset, 1, false); + assert.equal(unset.color, undefined); +}); + +test('removed CSS mode cannot disable GPU contacts; unsupported adapters fail closed', () => { + const root = { dataset: { uiTheme: 'cyber' }, style: { setProperty() {} } }; + const options = { + root, + body: { classList: { contains: () => false } }, + hud: { classList: { contains: () => true } }, + reducedMotion: false, + }; + root.dataset.cyberSonarMode = 'css'; + assert.equal(applyCyberSonarSettings({ mode: 'css' }, root).mode, undefined); + assert.equal(root.dataset.cyberSonarMode, undefined); + assert.equal(isCyberSonarActive(options), true); + assert.equal(isCyberContactSonarActive(options), true); + assert.equal( + applyCyberSonarSettings({ mode: 'invalid' }, root).mode, + undefined, + ); + assert.equal(isCyberContactSonarActive(options), true); + root.dataset.cyberSonarGpu = 'unsupported'; + assert.equal(isCyberContactSonarActive(options), false); +}); diff --git a/src/cyberSonarEngine.js b/src/cyberSonarEngine.js index f2d1b3c1a..1598c1403 100644 --- a/src/cyberSonarEngine.js +++ b/src/cyberSonarEngine.js @@ -1,10 +1,13 @@ -// The Cyber HUD sonar's contact treatment on a globe engine that draws it -// itself (render.setContactSonar, the `contactSonar` capability: Navara's -// sprite and point movers), the counterpart of src/cyberSonarScene.js on a -// Cesium viewer. Each drawn frame hands the engine the theme's state, the -// sweep's settings and its angle on the shared clock (the CSS sweep's), so -// the contacts and the rings turn together; while the sweep runs over -// visible contacts a parked scene draws at the fleet cadence, as on Cesium. +// The Cyber HUD sonar's contact treatment on the globe engine, which draws +// it (render.setContactSonar, the `contactSonar` capability: Navara's +// sprite and point movers, Cesium's native draw commands and models). Each +// drawn frame hands the engine the theme's state, the sweep's settings and +// its angle on the shared clock (the CSS sweep's), so the contacts and the +// rings turn together; while the sweep runs over visible contacts a parked +// scene draws at the fleet cadence. An engine that can lose its treatment +// (Cesium's command adapter is tested against one Cesium version) says so +// through contactSonarDiagnostics, and the theme then leaves native +// contacts as they are. import { engineCapabilities } from './globe/contract.js'; import { @@ -40,6 +43,9 @@ const contactCount = (engine) => .filter((set) => set.kind !== 'model') .reduce((count, set) => count + set.count, 0); +const UNSUPPORTED_MESSAGE = + 'Contact GPU unavailable. Native contacts remain visible.'; + /** * @param {import('./globe/contract.js').GlobeEngine} engine * @param {{ subscribe?: Function }} [manager] The layer manager: a @@ -52,6 +58,7 @@ export function createCyberSonarEngineScene( { now = () => performance.now(), root = globalThis.document?.documentElement, + documentRef = globalThis.document, setTimer = (callback, ms) => setTimeout(callback, ms), clearTimer = (id) => clearTimeout(id), } = {}, @@ -59,17 +66,39 @@ export function createCyberSonarEngineScene( if (!engineCapabilities(engine).contactSonar) return () => {}; let timer = null; let disposed = false; + // Not contract: what the engine's treatment holds (Cesium's), or null. + const diagnostics = () => engine.contactSonarDiagnostics?.() ?? null; // The Cyber contact theme counts on the contact adapter (cyberSonar.js - // isCyberContactThemeActive): this engine has one. - if (root?.dataset) root.dataset.cyberSonarGpu = 'supported'; + // isCyberContactThemeActive). Returns whether its support changed. + const syncSupport = () => { + const supported = diagnostics()?.supported !== false; + const support = supported ? 'supported' : 'unsupported'; + const status = documentRef?.getElementById?.('cyber-sonar-status'); + const message = supported ? '' : UNSUPPORTED_MESSAGE; + if (status && status.textContent !== message) status.textContent = message; + if (!root?.dataset || root.dataset.cyberSonarGpu === support) return false; + root.dataset.cyberSonarGpu = support; + return true; + }; + syncSupport(); const removeRender = engine.frames.preRender.addEventListener(() => { if (isCyberSonarActive({ root })) syncCyberSonarAnimation(root); engine.render.setContactSonar(cyberSonarEngineState(now(), root)); }); + // Contacts the sweep passes over: the engine's movers, and what its own + // treatment drew (Cesium's tinted models and visible native batches). + const contactsShown = () => { + if (contactCount(engine)) return true; + const drawn = diagnostics(); + return Boolean(drawn?.contacts || drawn?.gpu?.visibleCommands); + }; const removePostRender = engine.frames.postRender.addEventListener(() => { + // A treatment lost while the scene is parked (Sonar OFF, reduced + // motion) draws once more, so the native contacts come back. + if (syncSupport()) engine.render.request('cyber-sonar'); if (timer !== null) clearTimer(timer); timer = null; - if (isCyberContactSonarActive({ root }) && contactCount(engine)) + if (isCyberContactSonarActive({ root }) && contactsShown()) timer = setTimer(() => { timer = null; if (!disposed && isCyberContactSonarActive({ root })) diff --git a/src/cyberSonarEngine.test.mjs b/src/cyberSonarEngine.test.mjs index c991728a7..402288634 100644 --- a/src/cyberSonarEngine.test.mjs +++ b/src/cyberSonarEngine.test.mjs @@ -114,3 +114,55 @@ test('each drawn frame hands the engine the theme, the settings and the sweep an 'nothing after dispose', ); })); + +test("an engine's own contacts keep the sweep drawing, and a lost treatment wakes the scene once", () => + withCyberPage(async ({ root }) => { + const engine = createFakeEngine({ kind: 'cesium', contactSonar: true }); + // Cesium's report (src/globe/cesium/sonar.js), not contract. + const report = { + supported: true, + contacts: 0, + gpu: { visibleCommands: 0 }, + }; + engine.contactSonarDiagnostics = () => report; + const status = { textContent: '' }; + const timers = new Map(); + let nextTimer = 1; + const dispose = createCyberSonarEngineScene(engine, null, { + root, + documentRef: { + getElementById: (id) => (id === 'cyber-sonar-status' ? status : null), + }, + setTimer: (callback, ms) => { + timers.set(nextTimer, [callback, ms]); + return nextTimer++; + }, + clearTimer: (id) => timers.delete(id), + }); + assert.equal(root.dataset.cyberSonarGpu, 'supported'); + engine.fake.frame(); + assert.equal(timers.size, 0, 'culled batches must not schedule a draw'); + report.gpu.visibleCommands = 1; + engine.fake.frame(); + assert.equal(timers.size, 1, 'a visible batch keeps the sweep moving'); + report.gpu.visibleCommands = 0; + report.contacts = 2; + engine.fake.frame(); + assert.equal(timers.size, 1, 'and so does a tinted model'); + report.contacts = 0; + const before = engine.fake.renderRequests; + report.supported = false; + engine.fake.frame(); + assert.equal(root.dataset.cyberSonarGpu, 'unsupported'); + assert.match(status.textContent, /Native contacts remain visible/); + assert.equal( + engine.fake.renderRequests, + before + 1, + 'support loss explicitly wakes the parked scene', + ); + engine.fake.frame(); + assert.equal(engine.render.contactSonar().enabled, false); + assert.equal(engine.fake.renderRequests, before + 1, 'and only once'); + dispose(); + assert.equal(root.dataset.cyberSonarGpu, undefined); + })); diff --git a/src/cyberSonarScene.js b/src/cyberSonarScene.js deleted file mode 100644 index f07159cbf..000000000 --- a/src/cyberSonarScene.js +++ /dev/null @@ -1,211 +0,0 @@ -import * as Cesium from 'cesium'; -import { createCyberSonarGpu } from './cyberSonarGpu.js'; -import { - applyCyberSonarPrimitive, - createCyberSonarSampler, - isCyberContactThemeActive, - isCyberContactSonarActive, - isCyberSonarActive, - readCyberSonarSettings, - cyberSonarAngleDeg, - syncCyberSonarAnimation, - CYBER_SONAR_RENDER_INTERVAL_MS, -} from './cyberSonar.js'; - -const alive = (value) => value && !value.isDestroyed?.(); -const diagnostics = new WeakMap(); -export const getCyberSonarSceneDiagnostics = (viewer) => ({ - ...diagnostics.get(viewer), -}); - -export function visitCyberSonarModels(primitive, visit, visible = true) { - if (!alive(primitive)) return; - visible = visible && primitive.show !== false; - if (primitive instanceof Cesium.PrimitiveCollection) { - for (let i = 0; i < primitive.length; i++) - visitCyberSonarModels(primitive.get(i), visit, visible); - } else if (primitive instanceof Cesium.Model) { - visit(primitive, primitive, 'model', visible && primitive.ready); - } -} - -/** Native dots, icons and glyphs use GPU draw-command treatment. Models retain - * their existing reversible CPU color treatment without walking native records. - */ -export function createCyberSonarScene(viewer, manager) { - const scene = viewer?.scene; - if ( - !scene?.postUpdate?.addEventListener || - !scene?.preRender?.addEventListener - ) - return () => {}; - const tracked = new Map(); - const matrix = new Cesium.Matrix4(); - const position = new Cesium.Cartesian3(); - let epoch = 0, - timer = null, - disposed = false; - let sampler, width, height, active, count; - const stats = { contacts: 0, updateMs: 0, frames: 0 }; - const root = globalThis.document?.documentElement; - const gpu = createCyberSonarGpu(scene, () => ({ - ...readCyberSonarSettings(), - enabled: isCyberContactThemeActive(), - active: isCyberContactSonarActive(), - angle: cyberSonarAngleDeg(performance.now()), - })); - stats.gpu = gpu.stats; - let gpuSupported = gpu.stats.supported; - diagnostics.set(viewer, stats); - const restore = (visual, state) => { - if (!alive(state.owner)) return; - for (const key of state.keys) - applyCyberSonarPrimitive(visual, 1, false, undefined, key); - }; - const visit = (visual, owner, kind, visible) => { - let state = tracked.get(visual); - if (!visible) { - if (state) { - restore(visual, state); - tracked.delete(visual); - } - return; - } - let p = visual.position; - if (kind === 'model') - p = Cesium.Matrix4.getTranslation(visual.modelMatrix, position); - else if ( - p && - owner.modelMatrix && - !Cesium.Matrix4.equals(owner.modelMatrix, Cesium.Matrix4.IDENTITY) - ) - p = Cesium.Matrix4.multiplyByPoint(owner.modelMatrix, p, position); - if (!p) return; - const w = matrix[3] * p.x + matrix[7] * p.y + matrix[11] * p.z + matrix[15]; - const x = - (matrix[0] * p.x + matrix[4] * p.y + matrix[8] * p.z + matrix[12]) / w; - const y = - (matrix[1] * p.x + matrix[5] * p.y + matrix[9] * p.z + matrix[13]) / w; - if (w <= 0 || Math.abs(x) > 1.1 || Math.abs(y) > 1.1) return; - if (!state) { - state = { - owner, - keys: - kind === 'label' - ? ['fillColor', 'outlineColor', 'backgroundColor'] - : kind === 'marker' && visual.outlineColor - ? ['color', 'outlineColor'] - : ['color'], - epoch, - }; - tracked.set(visual, state); - } - state.epoch = epoch; - const factor = active - ? sampler.at((x + 1) * width * 0.5, (1 - y) * height * 0.5) - : sampler.neutral; - for (const key of state.keys) { - if (key === 'outlineColor' && visual.outlineWidth === 0) continue; - if (key === 'backgroundColor' && !visual.showBackground) continue; - applyCyberSonarPrimitive( - visual, - kind === 'label' ? sampler.label(factor) : factor, - true, - sampler, - key, - kind === 'model', - ); - } - count++; - }; - const update = () => { - if (disposed) return; - if (timer !== null) { - clearTimeout(timer); - timer = null; - } - if (!isCyberContactThemeActive()) { - for (const [visual, state] of tracked) restore(visual, state); - tracked.clear(); - stats.contacts = 0; - stats.updateMs = 0; - return; - } - const started = performance.now(); - width = scene.canvas.clientWidth; - height = scene.canvas.clientHeight; - sampler = createCyberSonarSampler(width, height, started); - active = isCyberContactSonarActive(); - Cesium.Matrix4.multiply( - scene.camera.frustum.projectionMatrix, - scene.camera.viewMatrix, - matrix, - ); - epoch++; - count = 0; - visitCyberSonarModels(scene.primitives, visit); - for (const [visual, state] of tracked) - if (state.epoch !== epoch) { - restore(visual, state); - tracked.delete(visual); - } - stats.contacts = count; - stats.updateMs = performance.now() - started; - stats.frames++; - // No contacts, no scanner-owned render demand. In continuous scenes the - // next rendered frame cancels this timer; parked scenes refresh at fleet - // cadence. Idle update-only ticks must not continually postpone the timer. - }; - let removeRender; - // Aircraft fleet writers run in preRender, while tracked models and entity - // visualizers update earlier. Compose AFTER all those owners and BEFORE - // Cesium uploads primitive attributes for this draw. Requeue at postUpdate - // so writers installed later (enable/re-enable/track) cannot overwrite sonar. - // This only reorders one callback; the contact walk runs once per actual draw. - const removeUpdate = scene.postUpdate.addEventListener(() => { - if (isCyberSonarActive()) syncCyberSonarAnimation(); - const supportChanged = gpuSupported !== gpu.stats.supported; - gpuSupported = gpu.stats.supported; - const support = gpu.stats.supported ? 'supported' : 'unsupported'; - if (root?.dataset && root.dataset.cyberSonarGpu !== support) - root.dataset.cyberSonarGpu = support; - // Shader setup runs after preRender's model tint. If it fails while the - // scene is parked (including Sonar OFF/reduced motion), draw once more so - // the now-unsupported contact path restores models before becoming idle. - if (supportChanged) scene.requestRender(); - const status = globalThis.document?.getElementById?.('cyber-sonar-status'); - const message = gpu.stats.supported - ? '' - : 'Contact GPU unavailable. Native contacts remain visible.'; - if (status && status.textContent !== message) status.textContent = message; - removeRender?.(); - removeRender = scene.preRender.addEventListener(update); - }); - const removePostRender = scene.postRender?.addEventListener(() => { - if (timer !== null) clearTimeout(timer); - timer = null; - if (isCyberContactSonarActive() && (count || gpu.stats.visibleCommands)) - timer = setTimeout(() => { - timer = null; - if (!disposed && isCyberContactSonarActive()) scene.requestRender(); - }, CYBER_SONAR_RENDER_INTERVAL_MS); - }); - const unsubscribe = manager?.subscribe?.((change) => { - if (change.type !== 'visibility') return; - scene.requestRender(); - }); - return () => { - if (disposed) return; - disposed = true; - removeUpdate(); - removeRender?.(); - removePostRender?.(); - gpu.destroy(); - if (root?.dataset) delete root.dataset.cyberSonarGpu; - unsubscribe?.(); - if (timer !== null) clearTimeout(timer); - for (const [visual, state] of tracked) restore(visual, state); - tracked.clear(); - diagnostics.delete(viewer); - }; -} diff --git a/src/cyberSonarScene.test.mjs b/src/cyberSonarScene.test.mjs deleted file mode 100644 index 7d0db8551..000000000 --- a/src/cyberSonarScene.test.mjs +++ /dev/null @@ -1,314 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; -import { - createCyberSonarSampler, - cyberSonarIntensityAtScreenPoint, - applyCyberSonarPrimitive, - applyCyberSonarSettings, - isCyberContactSonarActive, - isCyberSonarActive, -} from './cyberSonar.js'; -import { - createCyberSonarScene, - visitCyberSonarModels, - getCyberSonarSceneDiagnostics, -} from './cyberSonarScene.js'; - -test('frame sampler matches reference without per-contact settings access', () => { - let reads = 0; - const values = { - cyberSonarOpacity: '62', - cyberSonarSector: '40', - cyberSonarRange: '110', - }; - const root = { - dataset: new Proxy(values, { - get(o, k) { - reads++; - return o[k]; - }, - }), - }; - const frame = createCyberSonarSampler(1000, 800, 1234, root), - once = reads; - for (let x = 0; x < 1000; x += 33) - for (let y = 0; y < 800; y += 31) frame.at(x, y); - assert.equal(reads, once); - for (let x = 0; x < 1000; x += 33) - for (let y = 0; y < 800; y += 31) - assert.ok( - Math.abs( - frame.at(x, y) - - cyberSonarIntensityAtScreenPoint(x, y, 1000, 800, 1234, { - dataset: values, - }), - ) < 1e-10, - ); -}); - -test('model walk never enumerates native contact or EntityCluster records', () => { - const root = new Cesium.PrimitiveCollection(); - const points = root.add(new Cesium.PointPrimitiveCollection()); - points.add(); - points.get = () => { - throw new Error('must not enumerate contacts'); - }; - const cluster = root.add(new Cesium.EntityCluster()); - Object.defineProperty(cluster, '_billboardCollection', { - get() { - throw new Error('must not access cluster internals'); - }, - configurable: true, - }); - const seen = []; - visitCyberSonarModels(root, (...v) => seen.push(v)); - assert.equal(seen.length, 0); - delete cluster._billboardCollection; - root.destroy(); -}); - -test('models retain render-pass alpha and owner refresh survives teardown', () => { - const frame = createCyberSonarSampler(1000, 800, 0); - const model = { color: new Cesium.Color(1, 0.5, 0.2, 1) }; - applyCyberSonarPrimitive(model, 0.5, true, frame, 'color', true); - assert.equal(model.color.alpha, 1); - assert.ok(model.color.red < 1); - const owner = new Cesium.Color(0.2, 0.3, 0.4, 0.7); - model.color = owner; - applyCyberSonarPrimitive(model, 1, false); - assert.equal(model.color, owner); - const unset = {}; - applyCyberSonarPrimitive(unset, 0.5, true, frame, 'color', true); - applyCyberSonarPrimitive(unset, 1, false); - assert.equal(unset.color, undefined); -}); - -test('removed CSS mode cannot disable GPU contacts; unsupported adapters fail closed', () => { - const root = { dataset: { uiTheme: 'cyber' }, style: { setProperty() {} } }; - const options = { - root, - body: { classList: { contains: () => false } }, - hud: { classList: { contains: () => true } }, - reducedMotion: false, - }; - root.dataset.cyberSonarMode = 'css'; - assert.equal(applyCyberSonarSettings({ mode: 'css' }, root).mode, undefined); - assert.equal(root.dataset.cyberSonarMode, undefined); - assert.equal(isCyberSonarActive(options), true); - assert.equal(isCyberContactSonarActive(options), true); - assert.equal( - applyCyberSonarSettings({ mode: 'invalid' }, root).mode, - undefined, - ); - assert.equal(isCyberContactSonarActive(options), true); - root.dataset.cyberSonarGpu = 'unsupported'; - assert.equal(isCyberContactSonarActive(options), false); -}); - -test('native owner colors remain untouched and scene teardown releases listeners', () => { - const previous = globalThis.document; - const root = { dataset: { uiTheme: 'cyber', cyberSonar: 'on' } }; - let cockpit = false; - globalThis.document = { - documentElement: root, - body: { classList: { contains: () => cockpit } }, - getElementById: () => ({ classList: { contains: () => true } }), - }; - const primitives = new Cesium.PrimitiveCollection(); - const points = primitives.add(new Cesium.PointPrimitiveCollection()); - const point = points.add({ color: new Cesium.Color(0.3, 0.7, 0.5, 0.8) }); - const original = primitives.update; - const scene = { - primitives, - postUpdate: new Cesium.Event(), - preRender: new Cesium.Event(), - postRender: new Cesium.Event(), - canvas: { clientWidth: 1000, clientHeight: 800 }, - camera: { - frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY }, - viewMatrix: Cesium.Matrix4.IDENTITY, - }, - requestRender() {}, - }; - const viewer = { scene }; - const dispose = createCyberSonarScene(viewer); - try { - for (const enabled of ['on', 'off', 'on']) { - root.dataset.cyberSonar = enabled; - for (const opacity of [35, 84, 100]) { - root.dataset.cyberSonarOpacity = String(opacity); - point.color = new Cesium.Color(0.6, 0.4, 0.2, 0.7); - scene.postUpdate.raiseEvent(); - scene.preRender.raiseEvent(); - assert.equal(point.color.alpha, 0.7); - assert.equal(point.color.red, 0.6); - } - } - const previousTimeout = globalThis.setTimeout; - const previousClear = globalThis.clearTimeout; - let timers = 0; - globalThis.setTimeout = () => { - timers++; - return 1; - }; - globalThis.clearTimeout = () => {}; - try { - const gpu = getCyberSonarSceneDiagnostics(viewer).gpu; - gpu.commands = 6; - gpu.visibleCommands = 0; - scene.postRender.raiseEvent(); - assert.equal(timers, 0, 'culled batches must not schedule a sonar draw'); - gpu.visibleCommands = 1; - scene.postRender.raiseEvent(); - assert.equal(timers, 1, 'a visible batch keeps the sweep moving'); - root.dataset.cyberSonar = 'off'; - scene.postRender.raiseEvent(); - assert.equal(timers, 1, 'OFF does not schedule contact draws'); - } finally { - globalThis.setTimeout = previousTimeout; - globalThis.clearTimeout = previousClear; - } - cockpit = true; - scene.postUpdate.raiseEvent(); - scene.preRender.raiseEvent(); - root.dataset.uiTheme = 'tactical'; - scene.postUpdate.raiseEvent(); - scene.preRender.raiseEvent(); - dispose(); - dispose(); - assert.equal(primitives.update, original); - assert.equal(scene.postUpdate.numberOfListeners, 0); - assert.equal(scene.preRender.numberOfListeners, 0); - assert.equal(scene.postRender.numberOfListeners, 0); - assert.equal(root.dataset.cyberSonarGpu, undefined); - } finally { - dispose(); - primitives.destroy(); - globalThis.document = previous; - } -}); - -for (const reducedMotion of [false, true]) { - test(`shader failure requests one parked restoration draw with ${reducedMotion ? 'reduced motion' : 'Sonar OFF'}`, () => { - const previousDocument = globalThis.document; - const previousMatchMedia = globalThis.matchMedia; - const previousTimeout = globalThis.setTimeout; - const previousClear = globalThis.clearTimeout; - const root = { - dataset: { uiTheme: 'cyber', cyberSonar: reducedMotion ? 'on' : 'off' }, - }; - const status = { textContent: '' }; - globalThis.document = { - documentElement: root, - body: { classList: { contains: () => false } }, - getElementById: (id) => - id === 'cyber-sonar-status' - ? status - : { classList: { contains: () => true } }, - }; - globalThis.matchMedia = () => ({ matches: reducedMotion }); - let scheduled = 0; - globalThis.setTimeout = () => { - scheduled++; - return 1; - }; - globalThis.clearTimeout = () => {}; - // A model-shaped fixture exercises the actual scene walk without loading a - // glTF or allocating a WebGL context. Rendering remains the browser gate. - const nativeColor = new Cesium.Color(0.2, 0.4, 0.8, 1); - const model = Object.create(Cesium.Model.prototype, { - ready: { value: true }, - show: { value: true }, - color: { value: nativeColor.clone(), writable: true }, - modelMatrix: { value: Cesium.Matrix4.IDENTITY }, - }); - const primitives = new Cesium.PrimitiveCollection({ - destroyPrimitives: false, - }); - primitives.add(model); - const points = new Cesium.PointPrimitiveCollection(); - const nativeCommand = { - owner: points, - uniformMap: {}, - shaderProgram: { - vertexShaderSource: new Cesium.ShaderSource({ - sources: ['void main() {}'], - }), - }, - }; - primitives.update = (frame) => frame.commandList.push(nativeCommand); - let requests = 0; - const scene = { - primitives, - postUpdate: new Cesium.Event(), - preRender: new Cesium.Event(), - postRender: new Cesium.Event(), - canvas: { clientWidth: 1000, clientHeight: 800 }, - drawingBufferWidth: 1000, - drawingBufferHeight: 800, - camera: { - frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY }, - viewMatrix: Cesium.Matrix4.IDENTITY, - }, - requestRender() { - requests++; - }, - }; - const viewer = { scene }; - const dispose = createCyberSonarScene(viewer); - try { - scene.postUpdate.raiseEvent(); - scene.preRender.raiseEvent(); - assert.notDeepEqual( - model.color, - nativeColor, - 'Cyber neutral treatment precedes shader compilation', - ); - const frame = { commandList: [], passes: { render: true } }; - primitives.update(frame); - assert.equal(getCyberSonarSceneDiagnostics(viewer).gpu.supported, false); - assert.equal(frame.commandList[0], nativeCommand); - scene.postRender.raiseEvent(); - assert.equal( - scheduled, - 0, - 'inactive sweep has no timer to perform recovery', - ); - scene.postUpdate.raiseEvent(); - assert.equal(root.dataset.cyberSonarGpu, 'unsupported'); - assert.match(status.textContent, /Native contacts remain visible/); - assert.equal( - requests, - 1, - 'support loss explicitly wakes the parked scene', - ); - scene.preRender.raiseEvent(); - assert.deepEqual( - model.color, - nativeColor, - 'the requested draw restores native model color', - ); - assert.equal(getCyberSonarSceneDiagnostics(viewer).contacts, 0); - scene.postRender.raiseEvent(); - for (let i = 0; i < 3; i++) scene.postUpdate.raiseEvent(); - assert.equal( - requests, - 1, - 'unsupported idle ticks do not create a render loop', - ); - assert.equal(scheduled, 0); - dispose(); - scene.postUpdate.raiseEvent(); - assert.equal(requests, 1, 'disposed callbacks cannot wake the scene'); - } finally { - dispose(); - points.destroy(); - primitives.destroy(); - globalThis.document = previousDocument; - globalThis.matchMedia = previousMatchMedia; - globalThis.setTimeout = previousTimeout; - globalThis.clearTimeout = previousClear; - } - }); -} diff --git a/src/data/cameraFraming.js b/src/data/cameraFraming.js index a2d38b5ec..32c59f94c 100644 --- a/src/data/cameraFraming.js +++ b/src/data/cameraFraming.js @@ -1,9 +1,8 @@ -import * as Cesium from 'cesium'; - /** Camera range with breathing room in both viewport dimensions. */ export function rangeForBoundingSphere(viewer, radius) { const frustum = viewer.camera?.frustum; - const verticalFov = Number(frustum?.fov) || Cesium.Math.toRadians(60); + // Cesium's default frustum: 60°. + const verticalFov = Number(frustum?.fov) || Math.PI / 3; const aspectRatio = Math.max(0.5, Number(frustum?.aspectRatio) || 1); const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio); const limitingFov = Math.min(verticalFov, horizontalFov); diff --git a/src/data/focusDeemphasis.js b/src/data/focusDeemphasis.js index 4471f38f2..730009cb3 100644 --- a/src/data/focusDeemphasis.js +++ b/src/data/focusDeemphasis.js @@ -1,4 +1,4 @@ -import { worldToWindowCoordinates } from './render/cesiumProjection.js'; +import { worldToWindowCoordinates } from '../globe/cesium/sceneProjection.js'; import * as geo from '../geo/index.js'; // A window position through the engine projector the scene carries (the diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js index 1acab1c1c..751b111f8 100644 --- a/src/data/geoFileImport.js +++ b/src/data/geoFileImport.js @@ -1,10 +1,15 @@ /** Session-local geodata loading with bounded geometry and local-only file references. */ -import * as Cesium from 'cesium'; -import { cesiumSdkReady, loadCesiumSdk } from '../app/loadCesium.js'; import { createImportImageBudget } from './geoImportImages.js'; -import { createImportedPins } from './geoImportPins.js'; +import { readKmlRecords } from './geoImportKml.js'; +import { + geoJsonRecords, + importFeatureName, + recordCount, +} from './geoImportRecords.js'; import { msg } from '../i18n/marker.js'; +export { importFeatureName }; + /** Largest file accepted, in bytes. */ const MAX_IMPORT_BYTES = 50 * 1024 * 1024; /** Largest total a KMZ may inflate to — a zip bomb stops here. */ @@ -17,8 +22,6 @@ export const MAX_IMPORT_ENTITIES = 20_000; export const MAX_IMPORT_POSITIONS = 250_000; /** Most normalized style definitions and local image links in one KML/KMZ. */ const MAX_KML_LINKS = 2_000; -/** Longest plain-text name kept from a file's features. */ -const MAX_IMPORT_NAME_LENGTH = 120; /** What the file picker offers. */ export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx'; @@ -213,8 +216,8 @@ export function countGeoJsonGeometry( MultiPolygon: 3, }[node.type]; if (depth !== undefined) { - // Multipart geometries create one Cesium entity per component, even - // when the document wraps them in just one Feature. + // Multipart geometries create one record per component, even when + // the document wraps them in just one Feature. entities += node.type.startsWith('Multi') ? Array.isArray(node.coordinates) ? node.coordinates.length @@ -294,7 +297,7 @@ function overLimitError({ over, features }) { } // The import uses WGS84 ground footprints and a short name only. Remove -// loader-sensitive metadata before Cesium allocates properties or entity IDs. +// everything else before the records are built. function prepareGeoJson(geojson) { const stack = [geojson]; while (stack.length) { @@ -521,7 +524,7 @@ export function kmzImageResolver( * Strip everything from a parsed KML document that would make the page reach * outside the file. `resolveLocal(href)` returns a URL for an image packed in * the same KMZ (or null); every other href is removed, so icons fall back to - * Cesium's default pin and a ground overlay without a local image is dropped. + * the default pin and a ground overlay without a local image is dropped. * @param {Document} doc * @param {(href: string) => string|null} [resolveLocal] * @returns {{removed: number}} How many elements were taken out. @@ -558,8 +561,8 @@ export function sanitizeKmlDocument(doc, resolveLocal = () => null) { drop(overlay); continue; } - // An empty Icon explicitly hides the billboard in Cesium. Remove the - // blocked icon definition so the loader supplies its local default pin. + // An empty Icon explicitly hides the billboard. Remove the blocked + // icon definition so the reader supplies its local default pin. // Authored empty Icons (with no blocked href) still mean no marker. drop( icon?.localName === 'Icon' && icon.parentNode?.localName === 'IconStyle' @@ -755,8 +758,8 @@ export function deduplicateKmlStyles( } /** - * Count placemarks and vertices in a parsed KML document before Cesium reads - * it: every `` tuple and every `gx:coord`, plus normalized + * Count placemarks and vertices in a parsed KML document before it is + * read: every `` tuple and every `gx:coord`, plus normalized * style definitions and distinct image `` values (`links`). * Inline StyleMaps are references, not additional shared style definitions. * @param {Document} doc @@ -806,8 +809,9 @@ export function countKmlGeometry( ) throw new GeoImportError(msg('That KML has invalid geometry bounds.')); } - // Cesium creates entities for folders, a parent for every placemark, and - // a child for each geometry in a MultiGeometry, including nested containers. + // The budget counts what Cesium made entities of: folders, a parent for + // every placemark, and a child for each geometry in a MultiGeometry, + // including nested containers. if ( name === 'Placemark' || name === 'Folder' || @@ -953,95 +957,6 @@ export function gpxToGeoJson( return { type: 'FeatureCollection', features }; } -/** A feature name as short plain text (no control characters), or undefined. */ -export function importFeatureName(value) { - const text = String(value ?? '') - .replace(/[\u0000-\u001f\u007f]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - if (!text) return undefined; - return text.length > MAX_IMPORT_NAME_LENGTH - ? `${text.slice(0, MAX_IMPORT_NAME_LENGTH - 1)}…` - : text; -} - -/** - * Make every entity of an imported data source inert: no description, - * balloon, extended data or properties, no native label, and a name that is - * short plain text. Generated pin canvases share a data URL so Cesium uploads - * each image once, rather than allocating a texture entry for every entity. - * Returns how many entities were touched. - * @param {{entities: {values: object[]}}} dataSource - * @returns {number} - */ -export function scrubImportedEntities(dataSource) { - const collection = dataSource?.entities; - const list = collection?.values || []; - const images = new Map(); - collection?.suspendEvents?.(); - try { - for (let i = 0; i < list.length; i++) { - const entity = list[i]; - let name; - try { - name = importFeatureName( - typeof entity.name === 'string' ? entity.name : undefined, - ); - } catch { - name = undefined; - } - entity.name = name; - entity.description = undefined; - entity.label = undefined; - entity.properties = undefined; - if (entity.kml && typeof entity.kml === 'object') entity.kml = undefined; - if ( - entity.billboard?.heightReference?.getValue?.() === - Cesium.HeightReference.CLAMP_TO_GROUND - ) - entity.billboard.disableDepthTestDistance = Number.POSITIVE_INFINITY; - const property = entity.billboard?.image; - if (property?.isConstant === false) continue; - const image = property?.getValue?.(); - if (image?.tagName === 'CANVAS') { - let url = images.get(image); - if (!url) { - url = image.toDataURL(); - images.set(image, url); - } - entity.billboard.image = url; - } - } - } finally { - collection?.resumeEvents?.(); - } - return list.length; -} - -/** - * Take time out of an imported data source so adding it cannot move the app's - * shared clock: the data source reports no clock (the viewer adopts the clock - * of every added source that has one), every entity is available for all - * time. KML tracks have already become their original static vertices in - * `sanitizeKmlDocument`, so no sampled-position fallback is needed here. - * @param {object} dataSource - * @returns {object} The same data source. - */ -export function detachImportedTime(dataSource) { - Object.defineProperty(dataSource, 'clock', { - configurable: true, - enumerable: false, - get: () => undefined, - set: () => {}, - }); - const list = dataSource?.entities?.values || []; - for (let i = 0; i < list.length; i++) { - const entity = list[i]; - entity.availability = undefined; - } - return dataSource; -} - /** Repair only the first KML opening tag, scanning its delimiter once. */ export function repairKmlRootNamespaces(value, what = 'KML') { const text = String(value); @@ -1196,171 +1111,6 @@ function checkLocalImages(doc, entries = new Map(), main = '') { } } -// Cesium also processes KML in deferred tasks; an exception there does not -// reject its load promise. Keep a failed file from blocking the serial queue. -// The camera and canvas only feed network links, which sanitizing removed; a -// globe engine without a Cesium camera (Navara) passes its canvas alone. -async function loadKml(doc, { viewer, engine }, signal) { - let removeError; - try { - return await boundedImport( - Promise.race([ - Cesium.KmlDataSource.load(doc, { - camera: viewer?.scene.camera, - canvas: viewer?.scene.canvas ?? engine?.host.canvas, - clampToGround: true, - }), - new Promise((_, reject) => { - const failed = () => { - reject(new GeoImportError(msg('That KML could not be read.'))); - }; - globalThis.addEventListener?.('error', failed); - removeError = () => globalThis.removeEventListener?.('error', failed); - }), - ]), - signal, - ); - } finally { - removeError?.(); - } -} - -// Resolve a static file's ground pins in batches before they are visualized. -// Per-billboard CLAMP_TO_GROUND subscriptions scale poorly on large imports. -const groundPins = new WeakMap(); -async function settleGroundPins(dataSource, { viewer, engine }, signal) { - let pins = groundPins.get(dataSource); - if (!pins) { - pins = []; - for (const entity of dataSource.entities.values) { - const reference = entity.billboard?.heightReference?.getValue?.(); - if ( - reference === Cesium.HeightReference.CLAMP_TO_GROUND || - reference === Cesium.HeightReference.RELATIVE_TO_GROUND - ) { - const position = entity.position?.getValue?.(Cesium.JulianDate.now()); - if (!position) continue; - const cartographic = Cesium.Cartographic.fromCartesian(position); - if (!cartographic) - throw new GeoImportError( - msg('That file has invalid point coordinates.'), - ); - pins.push({ - entity, - original: position, - cartographic, - offset: - reference === Cesium.HeightReference.RELATIVE_TO_GROUND - ? cartographic.height - : 0, - }); - entity.billboard.heightReference = Cesium.HeightReference.NONE; - entity.billboard.disableDepthTestDistance = Number.POSITIVE_INFINITY; - } - } - groundPins.set(dataSource, pins); - } - for (let at = 0; at < pins.length; at += 256) { - signal?.throwIfAborted(); - const batch = pins.slice(at, at + 256); - const positions = batch.map( - ({ cartographic: c }) => - new Cesium.Cartographic(c.longitude, c.latitude, 0), - ); - // A globe engine without a viewer: its terrain under each pin. - if (!viewer && engine) { - for (let i = 0; i < batch.length; i++) { - const item = batch[i]; - const sampled = engine.surface.terrainHeightSupported - ? engine.surface.terrainHeight(positions[i]) - : undefined; - const ground = Number.isFinite(sampled) - ? sampled - : (item.groundHeight ?? 0); - item.groundHeight = ground; - const height = ground + item.offset; - item.entity.position = - Math.abs(height - item.cartographic.height) < 1e-6 - ? item.original - : Cesium.Cartesian3.fromRadians( - item.cartographic.longitude, - item.cartographic.latitude, - height, - ); - } - if (at + batch.length < pins.length) - await new Promise((resolve) => setTimeout(resolve, 0)); - continue; - } - const mesh = - viewer.scene?.globe?.show === false && - viewer.scene?.clampToHeightSupported; - // A coarse rendered ellipsoid mesh can report a height kilometres below - // zero. Its provider's exact height is zero even while a 3D map hides it; - // a buried starting point can make the downward mesh ray miss the surface. - const ellipsoid = - viewer.terrainProvider instanceof Cesium.EllipsoidTerrainProvider; - for (let i = 0; i < positions.length; i++) { - const sampled = ellipsoid - ? 0 - : viewer.scene?.globe?.getHeight?.(positions[i]); - positions[i].height = mesh - ? (batch[i].groundHeight ?? - Math.max(0, Number.isFinite(sampled) ? sampled : 0)) - : Number.isFinite(sampled) - ? sampled - : batch[i].groundHeight || 0; - } - const points = positions.map((c) => - Cesium.Cartesian3.fromRadians(c.longitude, c.latitude, c.height), - ); - const height = viewer.camera?.positionCartographic?.height; - if (mesh && Number.isFinite(height) && height < 250_000) { - const rectangle = viewer.camera.computeViewRectangle?.(); - const indices = []; - for (let i = 0; i < positions.length; i++) - if (rectangle && Cesium.Rectangle.contains(rectangle, positions[i])) - indices.push(i); - if (indices.length) { - const resolved = await boundedImport( - viewer.scene.clampToHeightMostDetailed( - indices.map((i) => { - const c = positions[i]; - return Cesium.Cartesian3.fromRadians( - c.longitude, - c.latitude, - Math.max(0, c.height), - ); - }), - ), - signal, - ); - for (let i = 0; i < indices.length; i++) - if (resolved[i]) points[indices[i]] = resolved[i]; - } - } - signal?.throwIfAborted(); - for (let i = 0; i < batch.length; i++) { - const item = batch[i]; - const c = points[i] && Cesium.Cartographic.fromCartesian(points[i]); - if (!c) - throw new GeoImportError( - msg( - 'Ground heights are not available for that file. Try again when the map has loaded.', - ), - ); - item.groundHeight = c.height; - c.height += item.offset; - item.entity.position = - Math.abs(c.height - item.cartographic.height) < 1e-6 - ? item.original - : Cesium.Cartesian3.fromRadians(c.longitude, c.latitude, c.height); - } - if (at + batch.length < pins.length) - await new Promise((resolve) => setTimeout(resolve, 0)); - } -} - async function boundedImport(promise, signal) { let timer, abort; try { @@ -1384,21 +1134,18 @@ async function boundedImport(promise, signal) { } /** - * Load a ground footprint, retaining explicit KML altitude modes. Call mount after - * adding the source; revoke owns pin and blob cleanup. + * Read a file into the import's plain records (src/data/geoImportRecords.js), + * keeping explicit KML altitude modes; no engine is involved, and the + * drawing is src/data/geoImportDisplay.js's. `revoke` releases the blob URLs + * of a KMZ's packed images, which the records name. * @param {File|Blob & {name?: string}} file - * @param {{viewer?: Cesium.Viewer, engine?: object, color?: string}} options - * `color` is a CSS color. Without a viewer, `engine` (a globe engine: - * Navara) supplies the ground under the pins, and the pins stay entities - * for that engine's own drawing (src/layers/shared/render/navaraStory.js). - * @returns {Promise<{dataSource: Cesium.DataSource, name: string, - * format: string, entityCount: number, removed: number, mount: Function, revoke: Function}>} + * @param {{color?: string, signal?: AbortSignal}} [options] `color` is the + * row's `#rrggbb`: GeoJSON and GPX draw in it; KML keeps its own styles. + * @returns {Promise<{records: import('./geoImportRecords.js').ImportedRecords, + * name: string, format: string, entityCount: number, removed: number, + * revoke: Function}>} */ -export async function loadGeoFile( - file, - { viewer = null, engine = null, color = '#39d0ff', signal } = {}, -) { - const globe = { viewer, engine }; +export async function loadGeoFile(file, { color = '#39d0ff', signal } = {}) { signal = signal ? AbortSignal.any([signal, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000); @@ -1417,18 +1164,12 @@ export async function loadGeoFile( throw new GeoImportError( msg('Only GeoJSON, KML, KMZ and GPX files can be imported.'), ); - // The import builds Cesium data sources on either globe: a Navara page - // loads the Cesium SDK first. - if (!cesiumSdkReady()) await boundedImport(loadCesiumSdk(), signal); - const stroke = Cesium.Color.fromCssColorString(color); const urls = []; - let pins; const revoke = () => { - pins?.destroy(); urls.splice(0).forEach((url) => URL.revokeObjectURL(url)); }; const name = importDisplayName(file.name); - let dataSource; + let records; let removed = 0; try { if (format === 'geojson' || format === 'gpx') { @@ -1444,18 +1185,7 @@ export async function loadGeoFile( throw new GeoImportError(msg('That file has nothing to draw.')); prepareGeoJson(geojson); bytes = null; - dataSource = await boundedImport( - Cesium.GeoJsonDataSource.load(geojson, { - clampToGround: true, - stroke, - fill: stroke.withAlpha(0.25), - strokeWidth: 3, - markerColor: stroke, - // No HTML description table is built from the file's properties. - describe: () => undefined, - }), - signal, - ); + records = geoJsonRecords(geojson, color); } else { let text; let entries; @@ -1484,7 +1214,7 @@ export async function loadGeoFile( deduplicateKmlStyles(doc); const normalizedSize = countKmlGeometry(doc); if (normalizedSize.over) throw overLimitError(normalizedSize); - dataSource = await loadKml(doc, globe, signal); + records = readKmlRecords(doc); } } catch (error) { revoke(); @@ -1495,7 +1225,7 @@ export async function loadGeoFile( { format: format.toUpperCase() }, ); } - const entityCount = dataSource.entities.values.length; + const entityCount = recordCount(records); if (entityCount > MAX_IMPORT_ENTITIES) { revoke(); throw new GeoImportError( @@ -1507,59 +1237,11 @@ export async function loadGeoFile( }, ); } - const now = Cesium.JulianDate.now(); - const drawable = dataSource.entities.values.some( - (e) => - e.billboard?.image?.getValue(now) || - e.point || - e.rectangle || - e.polyline?.positions?.getValue(now)?.length >= 2 || - e.wall?.positions?.getValue(now)?.length >= 2 || - e.polygon?.hierarchy?.getValue(now)?.positions?.length >= 3, - ); - if (!drawable) { + if (!entityCount) { revoke(); throw new GeoImportError(msg('That file has nothing to draw.')); } - scrubImportedEntities(dataSource); - try { - await settleGroundPins(dataSource, globe, signal); - } catch (error) { - revoke(); - throw error instanceof GeoImportError - ? error - : new GeoImportError( - msg('Ground heights could not be read. Try that file again.'), - ); - } - // Keep the clock invariant explicit even though unused KML dates have - // already been stripped and all supported geometries are static. - detachImportedTime(dataSource); - try { - pins = viewer - ? createImportedPins(dataSource, viewer) - : { mount() {}, sync() {}, destroy() {} }; - } catch { - revoke(); - throw new GeoImportError(msg('That file has invalid point graphics.')); - } - // The display name travels beside the data source (the file name, not + // The display name travels beside the records (the file name, not // anything the document says about itself). - return { - dataSource, - name, - format, - entityCount, - removed, - revoke, - mount: () => pins.mount(), - refreshGround: (refreshSignal) => - settleGroundPins( - dataSource, - globe, - refreshSignal - ? AbortSignal.any([refreshSignal, AbortSignal.timeout(15_000)]) - : AbortSignal.timeout(15_000), - ).then(() => pins.sync()), - }; + return { records, name, format, entityCount, removed, revoke }; } diff --git a/src/data/geoFileImport.test.mjs b/src/data/geoFileImport.test.mjs index 0c7f80ec9..59a864f4b 100644 --- a/src/data/geoFileImport.test.mjs +++ b/src/data/geoFileImport.test.mjs @@ -2,7 +2,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { deflateRawSync } from 'node:zlib'; import { spawnSync } from 'node:child_process'; -import * as Cesium from 'cesium'; import { GeoImportError, repairKmlRootNamespaces, @@ -12,10 +11,8 @@ import { countGeoJsonGeometry, countKmlGeometry, deduplicateKmlStyles, - detachImportedTime, gpxToGeoJson, importFeatureName, - scrubImportedEntities, sanitizeKmlDocument, detectGeoFileFormat, importDisplayName, @@ -28,104 +25,59 @@ import { } from './geoFileImport.js'; import { createImportImageBudget, importImageSize } from './geoImportImages.js'; -test('real loader grounds XYZ GeoJSON and strips loader-sensitive metadata before allocation', async () => { - const previous = Cesium.PinBuilder.prototype.fromColor; - Cesium.PinBuilder.prototype.fromColor = () => ({ - toDataURL: () => 'data:image/png;base64,local', - tagName: 'CANVAS', - }); - try { - const object = { - type: 'FeatureCollection', - crs: { type: 'name', properties: { name: 'unsupported' } }, - features: [ - { - type: 'Feature', - id: 'kartaview-owned', - properties: { name: 'Test', addProperty: 1, 'marker-symbol': 'rail' }, - geometry: { type: 'Point', coordinates: [1, 2, 999] }, - }, - { - type: 'Feature', - geometry: { - type: 'Polygon', - coordinates: [ - [ - [1, 2, 0], - [2, 2, 0], - [2, 3, 0], - [1, 2, 0], - ], - ], - }, - }, - ], - }; - const loaded = await loadGeoFile( - new File([JSON.stringify(object)], 'xyz.geojson'), +test('real loader grounds XYZ GeoJSON and keeps only names and footprints in its records', async () => { + const object = { + type: 'FeatureCollection', + crs: { type: 'name', properties: { name: 'unsupported' } }, + features: [ { - viewer: { - scene: { - frameState: { mode: Cesium.SceneMode.SCENE3D }, - primitives: new Cesium.PrimitiveCollection(), - globe: { getHeight: () => 1234 }, - }, + type: 'Feature', + id: 'kartaview-owned', + properties: { name: 'Test', addProperty: 1, 'marker-symbol': 'rail' }, + geometry: { type: 'Point', coordinates: [1, 2, 999] }, + }, + { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [1, 2, 0], + [2, 2, 0], + [2, 3, 0], + [1, 2, 0], + ], + ], }, }, - ); - const [point, polygon] = - loaded.dataSource.importedEntities || loaded.dataSource.entities.values; - assert.notEqual(point.id, 'kartaview-owned'); - assert.equal(point.name, 'Test'); - assert.equal(point.properties, undefined); - assert.equal( - point.billboard.heightReference.getValue(), - Cesium.HeightReference.NONE, - ); - assert.equal(point.billboard.disableDepthTestDistance.getValue(), Infinity); - assert.ok( - Math.abs( - Cesium.Cartographic.fromCartesian( - point.position.getValue(Cesium.JulianDate.now()), - ).height - 1234, - ) < 1e-6, - ); - assert.notEqual(polygon.polygon.perPositionHeight?.getValue(), true); - assert.equal(loaded.dataSource.clock, undefined); - assert.equal( - loaded.dataSource.entities.values.length, - 1, - 'only polygon geometry remains in the per-frame entity collection', - ); - assert.equal(loaded.dataSource.importedBillboards.length, 1); - const update = Cesium.BillboardCollection.prototype.update; - let passes = 0; - Cesium.BillboardCollection.prototype.update = () => passes++; - try { - const collection = loaded.dataSource.importedBillboards; - collection.update({ passes: { pick: true, offscreen: true } }); - assert.equal(passes, 0, 'height picks ignore imported markers'); - collection.update({ passes: { pick: true, offscreen: false } }); - collection.update({ passes: { render: true, offscreen: true } }); - assert.equal(passes, 2, 'ordinary picks and screenshots include markers'); - } finally { - Cesium.BillboardCollection.prototype.update = update; - } - - loaded.mount(); - loaded.dataSource.show = false; - assert.equal(loaded.dataSource.importedBillboards.show, false); - loaded.dataSource.show = true; - await loaded.refreshGround(); - assert.equal( - point.billboard.heightReference.getValue(), - Cesium.HeightReference.NONE, - ); - loaded.revoke(); - assert.ok(loaded.dataSource.importedBillboards.isDestroyed()); - } finally { - Cesium.PinBuilder.prototype.fromColor = previous; - } + ], + }; + const loaded = await loadGeoFile( + new File([JSON.stringify(object)], 'xyz.geojson'), + { color: '#ffb547' }, + ); + assert.equal(loaded.format, 'geojson'); + assert.equal(loaded.entityCount, 2); + const [point] = loaded.records.points; + const [polygon] = loaded.records.polygons; + assert.deepEqual(Object.keys(point).sort(), [ + 'altitude', + 'icon', + 'name', + 'position', + ]); + assert.equal(point.name, 'Test'); + assert.equal(point.altitude, 'ground', 'an XYZ point stands on the ground'); + assert.deepEqual(point.position, [1, 2, 0]); + // Cesium's GeoJSON pin in the row colour (a marker-symbol is ignored). + assert.equal(point.icon.image.pinSize, 48); + assert.deepEqual(point.icon.image.pin, [1, 181 / 255, 71 / 255, 1]); + assert.equal(point.icon.origin, 'bottom'); + assert.equal(polygon.raised, false, 'XYZ polygons drape'); + assert.deepEqual(polygon.fill, [1, 181 / 255, 71 / 255, 0.25]); + assert.equal(polygon.name, undefined); + assert.equal('dataSource' in loaded, false, 'no Cesium data source'); + loaded.revoke(); }); test('real loader refuses empty files and malformed nested geometry before allocating entities', async () => { @@ -144,9 +96,7 @@ test('real loader refuses empty files and malformed nested geometry before alloc ].map((coordinates) => ({ type: 'Point', coordinates })), ]) await assert.rejects( - loadGeoFile(new File([JSON.stringify(object)], 'bad.geojson'), { - viewer: {}, - }), + loadGeoFile(new File([JSON.stringify(object)], 'bad.geojson')), GeoImportError, ); }); @@ -235,90 +185,11 @@ test('pending file reads can be cancelled without waiting for the loader timeout size: 1, arrayBuffer: () => new Promise(() => {}), }; - const promise = loadGeoFile(file, { viewer: {}, signal: controller.signal }); + const promise = loadGeoFile(file, { signal: controller.signal }); controller.abort(new Error('cancelled by teardown')); await assert.rejects(promise, /cancelled by teardown/); }); -test('a hidden coarse globe never seeds a mesh-clamp ray below the surface', async () => { - const previous = Cesium.PinBuilder.prototype.fromColor; - Cesium.PinBuilder.prototype.fromColor = () => ({ - tagName: 'CANVAS', - toDataURL: () => 'data:image/png;base64,local', - }); - const starts = []; - try { - for (const terrainProvider of [new Cesium.EllipsoidTerrainProvider(), {}]) { - const viewer = { - terrainProvider, - camera: { - positionCartographic: { height: 100000 }, - computeViewRectangle: () => Cesium.Rectangle.MAX_VALUE, - }, - scene: { - frameState: { mode: Cesium.SceneMode.SCENE3D }, - globe: { show: false, getHeight: () => -12311 }, - clampToHeightSupported: true, - clampToHeightMostDetailed: async (points) => - points.map((p) => { - const c = Cesium.Cartographic.fromCartesian(p); - starts.push(c.height); - return c.height < -9000 - ? undefined - : Cesium.Cartesian3.fromRadians(c.longitude, c.latitude, 2000); - }), - }, - }; - const loaded = await loadGeoFile( - new File( - [JSON.stringify({ type: 'Point', coordinates: [1, 2] })], - 'surface.geojson', - ), - { viewer }, - ); - assert.ok( - Math.abs(starts.at(-1)) < 1e-6, - `ray started at ${starts.at(-1)}`, - ); - assert.ok( - Math.abs( - Cesium.Cartographic.fromCartesian( - loaded.dataSource.importedBillboards.get(0).position, - ).height - 2000, - ) < 1e-6, - ); - const sampled = starts.length; - viewer.camera.computeViewRectangle = () => undefined; - await loaded.refreshGround(); - assert.equal( - starts.length, - sampled, - 'distant points do not start a detailed mesh sweep', - ); - assert.ok( - Math.abs( - Cesium.Cartographic.fromCartesian( - loaded.dataSource.importedBillboards.get(0).position, - ).height - 2000, - ) < 1e-6, - 'the last valid mesh height survives outside the camera footprint', - ); - const point = loaded.dataSource.importedEntities[0]; - const other = new Cesium.Entity({ id: 'unrelated-aircraft' }); - viewer.trackedEntity = starts.length === 1 ? other : point; - viewer.selectedEntity = point; - loaded.revoke(); - assert.equal(viewer.selectedEntity, undefined); - assert.equal( - viewer.trackedEntity, - starts.length === 1 ? other : undefined, - ); - } - } finally { - Cesium.PinBuilder.prototype.fromColor = previous; - } -}); - const bytes = (text) => new TextEncoder().encode(text); /** A minimal zip writer: stored (0) or deflated (8) entries, optional flags. */ @@ -1050,71 +921,14 @@ test('a KMZ image gets one blob URL however many hrefs name it', () => { assert.deepEqual(kept, ['blob:test/1', 'blob:test/2']); }); -test('imported entities keep only a short plain-text name: no description, label or properties', () => { - const ds = new Cesium.CustomDataSource('t'); - const e = ds.entities.add({ - name: 'Trail\u0007 head\n' + 'x'.repeat(300), - description: '', - label: { text: 'Trail head' }, - properties: { note: '' }, - position: Cesium.Cartesian3.fromDegrees(1, 2), - }); - e.kml = { extendedData: { note: { value: '