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.
This commit is contained in:
German Khokhlov committed 2026-11-07 12:45:21 +04:00
1 parent c079b329d0
commit 97c4dc76ce
130 files changed
+4763 -3495

No files matched your search

+20 -4
View File
@@ -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.
+30 -18
View File
@@ -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)
+56 -7
View File
@@ -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
+3 -3
View File
@@ -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",
+4 -3
View File
@@ -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)),
);
}
+6 -6
View File
@@ -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"
}
+28 -13
View File
@@ -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"
]
+72 -49
View File
@@ -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",
+124 -63
View File
@@ -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 {
+64
View File
@@ -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()];
},
};
});
}
+66 -90
View File
@@ -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(`<Placemark>${point}</Placemark>`);
@@ -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(
'<Placemark><Point><altitudeMode>absolute</altitudeMode><coordinates>-122.5,37.7,5000</coordinates></Point></Placemark>',
@@ -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) {
+69 -85
View File
@@ -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 <input type=file>.
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(
+4 -4
View File
@@ -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) },
+4 -6
View File
@@ -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(
+3 -3
View File
@@ -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(() =>
+3 -3
View File
@@ -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,
+4 -2
View File
@@ -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']) {
+11 -15
View File
@@ -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,
+3 -2
View File
@@ -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);
};
+4 -3
View File
@@ -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.
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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');
+3 -6
View File
@@ -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
+2 -23
View File
@@ -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).
+3 -3
View File
@@ -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']);
});
+3 -4
View File
@@ -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,
+7 -7
View File
@@ -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(
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+20 -2
View File
@@ -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,
+96
View File
@@ -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);
});
+39 -10
View File
@@ -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 }))
+52
View File
@@ -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);
}));
-211
View File
@@ -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);
};
}
-314
View File
@@ -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;
}
});
}
+2 -3
View File
@@ -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);
+1 -1
View File
@@ -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
+36 -354
View File
@@ -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 `<coordinates>` tuple and every `gx:coord`, plus normalized
* Count placemarks and vertices in a parsed KML document before it is
* read: every `<coordinates>` tuple and every `gx:coord`, plus normalized
* style definitions and distinct image `<href>` 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 };
}
+57 -346
View File
@@ -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 <b>head</b>\n' + 'x'.repeat(300),
description: '<img src="https://example.invalid/x.png" onerror="alert(1)">',
label: { text: 'Trail head' },
properties: { note: '<script>alert(1)</script>' },
position: Cesium.Cartesian3.fromDegrees(1, 2),
});
e.kml = { extendedData: { note: { value: '<iframe src=//x>' } } };
ds.entities.add({ name: 42, position: Cesium.Cartesian3.fromDegrees(1, 2) });
assert.equal(scrubImportedEntities(ds), 2);
assert.equal(e.description, undefined);
assert.equal(e.label, undefined);
assert.equal(e.properties, undefined);
assert.equal(e.kml, undefined);
assert.ok(e.name.startsWith('Trail <b>head</b> x'));
assert.ok(e.name.length <= 120 && !/[\u0000-\u001f]/.test(e.name));
assert.equal(ds.entities.values[1].name, undefined);
test('feature names are short plain text', () => {
const name = importFeatureName('Trail\u0007 <b>head</b>\n' + 'x'.repeat(300));
assert.ok(name.startsWith('Trail <b>head</b> x'));
assert.ok(name.length <= 120 && !/[\u0000-\u001f]/.test(name));
assert.equal(importFeatureName(42), '42');
assert.equal(importFeatureName(' '), undefined);
});
test('generated pin canvases are encoded once and shared without changing authored image URLs', () => {
const ds = new Cesium.CustomDataSource('pins');
let encoded = 0;
const canvas = {
tagName: 'CANVAS',
toDataURL: () => {
encoded++;
return 'data:image/png;base64,shared-pin';
},
};
for (let i = 0; i < 1000; i++)
ds.entities.add({
billboard: { image: canvas },
label: { text: 'discard' },
properties: { discard: true },
});
const authored = ds.entities.add({
billboard: { image: 'blob:packed-image' },
});
const dynamicImage = new Cesium.CallbackProperty(() => canvas, false);
const dynamic = ds.entities.add({ billboard: { image: dynamicImage } });
let notifications = 0;
ds.entities.collectionChanged.addEventListener(() => notifications++);
scrubImportedEntities(ds);
assert.equal(encoded, 1);
assert.equal(
notifications,
1,
'normalization publishes one complete collection change',
);
for (const e of ds.entities.values.slice(0, 1000))
assert.equal(
e.billboard.image.getValue(),
'data:image/png;base64,shared-pin',
);
assert.equal(authored.billboard.image.getValue(), 'blob:packed-image');
assert.equal(
dynamic.billboard.image,
dynamicImage,
'time-dependent image properties stay intact',
);
});
test('unused KML time and camera metadata is removed before loading without changing geometry', () => {
const coordinates = xmlEl('coordinates', {}, '1,2,0 3,4,0');
const placemark = xmlEl('Placemark', {}, [
@@ -1133,47 +947,6 @@ test('unused KML time and camera metadata is removed before loading without chan
assert.equal(coordinates.textContent, '1,2,0 3,4,0');
});
test('a dated import exposes no clock and draws for all time', () => {
// A data source with its own clock, as KmlDataSource has for a dated file.
class DatedSource extends Cesium.CustomDataSource {
get clock() {
return this._testClock;
}
}
const ds = new DatedSource('dated');
ds._testClock = new Cesium.DataSourceClock();
const start = Cesium.JulianDate.fromIso8601('2020-01-01T00:00:00Z');
const stop = Cesium.JulianDate.fromIso8601('2020-01-01T01:00:00Z');
const span = new Cesium.TimeIntervalCollection([
new Cesium.TimeInterval({ start, stop }),
]);
// KML tracks become their original static vertices before Cesium loading.
const track = ds.entities.add({
availability: span,
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray([10, 10, 10.1, 10.1]),
clampToGround: true,
},
});
const recorded = track.polyline.positions;
const pin = ds.entities.add({
availability: span,
position: Cesium.Cartesian3.fromDegrees(1, 1),
});
assert.ok(ds.clock);
detachImportedTime(ds);
assert.equal(ds.clock, undefined);
// The viewer reads `clock` from every added source; setting it is ignored.
ds.clock = new Cesium.DataSourceClock();
assert.equal(ds.clock, undefined);
assert.equal(pin.availability, undefined);
assert.ok(pin.isAvailable(Cesium.JulianDate.now()));
assert.equal(track.availability, undefined);
assert.equal(track.polyline.positions, recorded);
assert.equal(recorded.getValue(Cesium.JulianDate.now()).length, 2);
assert.ok(track.polyline.clampToGround.getValue());
});
test('KML root namespace repair respects quoted delimiters and preserves the document tail', () => {
const text =
'<KML note="a > b" xmlns:x="first" xmlns:x="second"><Placemark xsi:type="test"/></KML>';
@@ -1262,65 +1035,3 @@ test('KML namespace repair ignores declaration text inside quoted attribute valu
/note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/,
);
});
test('on a globe engine without a viewer the pins stay entities on the engine terrain', async () => {
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
const previous = Cesium.PinBuilder.prototype.fromColor;
Cesium.PinBuilder.prototype.fromColor = () => ({
toDataURL: () => 'data:image/png;base64,local',
tagName: 'CANVAS',
});
try {
const engine = createFakeEngine({ kind: 'navara' });
engine.fake.script('terrainHeight', 321);
const object = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { name: 'Pin' },
geometry: { type: 'Point', coordinates: [1, 2] },
},
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[1, 2],
[2, 3],
],
},
},
],
};
const loaded = await loadGeoFile(
new File([JSON.stringify(object)], 'pins.geojson'),
{ engine },
);
const entities = loaded.dataSource.entities.values;
assert.equal(entities.length, 2, 'no Cesium billboard collection');
assert.equal(loaded.dataSource.importedBillboards, undefined);
const [pin] = entities.filter((entity) => entity.billboard);
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(
pin.position.getValue(Cesium.JulianDate.now()),
).height - 321,
) < 1e-3,
);
// A later ground refresh reads the engine again.
engine.fake.script('terrainHeight', 50);
await loaded.refreshGround();
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(
pin.position.getValue(Cesium.JulianDate.now()),
).height - 50,
) < 1e-3,
);
loaded.mount();
loaded.revoke();
} finally {
Cesium.PinBuilder.prototype.fromColor = previous;
}
});
+438
View File
@@ -0,0 +1,438 @@
// One imported file (UTILITIES ▸ Import) drawn on the globe contract, the
// same on every engine, from its plain records (src/data/geoImportRecords.js),
// with Cesium's look as the reference:
// - polygons: one ground 'polygon' set of their fills; a raised polygon
// (KML absolute or relative) also gets its outline as lines at its
// heights, as Cesium drew it (its fill drapes: the ground has no heights);
// - ground lines: one ground 'corridor' set in pixels, over the fills;
// - lines in the air: one engine line each;
// - points: one sprite set per look, each sprite a square centred on its
// point holding the billboard where Cesium placed it (src/data/
// geoImportPins.js); points on the ground draw over the scene and are
// hidden beyond the horizon instead, as Cesium's clamped billboards
// ignored the depth test;
// - ground overlays: one image imagery layer each over its rectangle.
// Nothing is pickable: an imported file has no card. Ground heights under
// the points come from engine.surface, sampled again by refreshGround().
import * as geo from '../geo/index.js';
import { normalizeGroundItem } from '../globe/ground.js';
import { composeIcon } from './geoImportPins.js';
const POLYGON_ORDER = 0;
const LINE_ORDER = 1;
// Over the layers' rasters, as Cesium's ground rectangle drew over imagery.
const OVERLAY_ORDER = 30;
// The horizon test lifts each point by this much (as GhostMaps does):
// ground can lie below the ellipsoid.
const HORIZON_LIFT_M = 2000;
// Under this camera height, points on 3D tiles are clamped to the tiles
// drawn now; higher up the terrain answers (Cesium's 250 km).
const CLAMP_TILES_BELOW_M = 250_000;
const BATCH = 256;
let serial = 0;
const imageFromUrl = async (url) => {
const image = new Image();
image.src = url;
await image.decode();
return image;
};
// A ground item the contract accepts, or null (a degenerate ring or line).
function checked(kind, item) {
try {
normalizeGroundItem(kind, item);
return item;
} catch {
return null;
}
}
const flat = (positions) => positions.map(([lon, lat]) => [lon, lat]);
const cartesian = ([lon, lat, height = 0]) =>
geo.cartesian3.fromDegrees(lon, lat, height);
/** One key per drawn look of an icon, so equal pins share a sprite set. */
const lookKey = (icon, depth) =>
JSON.stringify([
icon.image,
icon.widthPx,
icon.heightPx,
icon.scale,
icon.origin,
icon.offsetPx,
icon.subRegion,
icon.scaleByDistance,
depth,
]);
/**
* Draw one imported file's records on `engine`. Resolves once its icons
* are decoded and every set is committed.
* @param {import('../globe/contract.js').GlobeEngine} engine
* @param {import('./geoImportRecords.js').ImportedRecords} records
* @param {{ visible?: boolean, loadImage?: (url: string) => Promise<object>,
* createCanvas?: () => object, pixelRatio?: number }} [options]
* `loadImage` and `createCanvas` are test seams (icon decoding and the
* sprite canvases).
* @returns {Promise<{ report: { drawn: number, undrawn: number,
* kinds: Object<string, number> }, setShow: (visible: boolean) => void,
* refreshGround: (signal?: AbortSignal) => Promise<void>,
* destroy: () => void }>}
*/
export async function drawImportedFile(
engine,
records,
{
visible = true,
loadImage = imageFromUrl,
createCanvas = () => document.createElement('canvas'),
pixelRatio = Math.max(1, Math.min(2, globalThis.devicePixelRatio || 1)),
} = {},
) {
if (!engine?.ground || !engine?.movers || !engine?.lines)
throw new TypeError('The imported file display needs a globe engine');
const prefix = `geo-import-${++serial}`;
const request = () => engine.render.request('geo-import');
let shown = visible;
let destroyed = false;
let removeCull = null;
const groundSets = [];
const lines = [];
const layers = [];
const looks = new Map();
let drawn = 0;
function release() {
destroyed = true;
removeCull?.();
removeCull = null;
for (const set of groundSets.splice(0)) set.destroy();
for (const line of lines.splice(0)) line.destroy();
for (const layer of layers.splice(0)) layer.destroy();
for (const look of looks.values()) look.set?.destroy();
looks.clear();
}
try {
// Fills under ground lines; a plain-colour overlay is a fill too.
const fills = [];
for (const polygon of records.polygons) {
let any = false;
if (polygon.fill) {
const item = checked('polygon', {
rings: polygon.rings.map(flat),
fill: polygon.fill,
});
if (item) {
fills.push(item);
any = true;
}
}
if (polygon.raised && polygon.outline)
for (const ring of polygon.rings) {
if (ring.length < 2) continue;
const closed = [...ring, ring[0]].map(cartesian);
const line = engine.lines.createLine({
id: `${prefix}-outline-${lines.length}`,
widthPx: 1,
color: polygon.outline.color,
});
line.setPositions(closed);
line.show(shown);
lines.push(line);
any = true;
}
if (any) drawn++;
}
for (const overlay of records.overlays) {
if (overlay.url) {
layers.push(
engine.imagery.createLayer({
id: `${prefix}-overlay-${layers.length}`,
source: {
kind: 'image',
url: overlay.url,
rectangle: overlay.rectangle,
},
// Cesium's transparent image material: at most 0.99.
alpha: Math.min(0.99, overlay.color[3]),
show: shown,
order: OVERLAY_ORDER,
}),
);
drawn++;
continue;
}
const { west, south, east, north } = overlay.rectangle;
const item = checked('cell', { west, south, east, north });
if (!item) continue;
const rings = [
[
[west, south],
[east < west ? east + 360 : east, south],
[east < west ? east + 360 : east, north],
[west, north],
],
];
fills.push({ rings, fill: overlay.color });
drawn++;
}
if (fills.length) {
const set = engine.ground.createSet({
id: `${prefix}-polygons`,
kind: 'polygon',
order: POLYGON_ORDER,
});
set.replace(fills);
set.commit();
set.show(shown);
groundSets.push(set);
}
const corridors = [];
for (const record of records.lines) {
if (record.ground) {
const item = checked('corridor', {
positions: flat(record.positions),
color: record.color,
widthPx: record.widthPx,
});
if (!item) continue;
corridors.push(item);
} else {
const line = engine.lines.createLine({
id: `${prefix}-line-${lines.length}`,
widthPx: record.widthPx,
color: record.color,
});
line.setPositions(record.positions.map(cartesian));
line.show(shown);
lines.push(line);
}
drawn++;
}
if (corridors.length) {
const set = engine.ground.createSet({
id: `${prefix}-lines`,
kind: 'corridor',
order: LINE_ORDER,
});
set.replace(corridors);
set.commit();
set.show(shown);
groundSets.push(set);
}
for (const point of records.points) {
const depth = point.altitude === 'absolute' ? 'normal' : 'always';
const key = lookKey(point.icon, depth);
let look = looks.get(key);
if (!look)
looks.set(
key,
(look = { icon: point.icon, depth, pins: [], set: null, count: 0 }),
);
const [lon, lat, height = 0] = point.position;
look.pins.push({
lon,
lat,
offset: point.altitude === 'relative' ? height : 0,
height: point.altitude === 'absolute' ? height : null,
ground: undefined,
position: null,
horizon: null,
show: true,
color: point.icon.color ?? [1, 1, 1, 1],
rotation: point.icon.rotation ?? 0,
});
}
await Promise.all(
[...looks.values()].map(async (look) => {
const url = look.icon.image?.url;
let image;
if (url) {
try {
image = await loadImage(url);
} catch {
// An icon that does not decode draws nothing, as a billboard
// without an image.
return;
}
}
if (destroyed) return;
const { canvas, side } = composeIcon(look.icon, {
image,
createCanvas,
ratio: pixelRatio,
});
look.set = engine.movers.createSet({
id: `${prefix}-pins-${look.depth}-${looks.size}-${++serial}`,
kind: 'sprite',
capacity: look.pins.length,
depthTest: look.depth,
pickable: false,
sprite: {
sizePx: side,
atlas: { canvas, cells: { icon: [0, 0, 1, 1] } },
...(look.icon.scaleByDistance
? { scaleByDistance: [...look.icon.scaleByDistance] }
: {}),
},
});
drawn += look.pins.length;
}),
);
if (destroyed) throw new Error('The imported file was removed');
settle(heightContext());
for (const look of looks.values()) writePins(look);
syncCull();
} catch (error) {
release();
throw error;
}
request();
// Where heights come from now: the terrain, or the 3D tiles under a low
// camera when they are the drawn surface.
function heightContext() {
const surface = engine.surface;
const tiles =
engine.imagery?.surface?.() === 'tiles' &&
surface.clampToHeightSupported &&
engine.camera.height() < CLAMP_TILES_BELOW_M;
const exclude = [...looks.values()].map((look) => look.set).filter(Boolean);
return { surface, tiles, exclude };
}
// The ground under a pin, falling back to the last known one.
function groundUnder(pin, { surface, tiles, exclude }) {
const at = geo.cartographic.fromDegrees(pin.lon, pin.lat, 0);
const terrain = surface.terrainHeightSupported
? surface.terrainHeight(at)
: undefined;
// A coarse ellipsoid mesh under 3D tiles can answer far below zero.
let ground = Number.isFinite(terrain)
? tiles
? Math.max(0, terrain)
: terrain
: (pin.ground ?? 0);
if (tiles) {
const top = surface.clampToHeight(
geo.cartesian3.fromDegrees(pin.lon, pin.lat, Math.max(0, ground)),
exclude,
);
const height = top && geo.cartographic.fromCartesian(top)?.height;
ground = Number.isFinite(height) ? height : (pin.ground ?? ground);
}
return ground;
}
function settle(context, from = 0, to = Infinity) {
let at = 0;
for (const look of looks.values())
for (const pin of look.pins) {
if (at++ < from || at > to) continue;
if (pin.height === null) pin.ground = groundUnder(pin, context);
const height = pin.height ?? pin.ground + pin.offset;
pin.position = geo.cartesian3.fromDegrees(pin.lon, pin.lat, height);
pin.horizon = geo.cartesian3.fromDegrees(
pin.lon,
pin.lat,
height + HORIZON_LIFT_M,
);
}
}
function writePins(look) {
const { set, pins } = look;
if (!set) return;
const culled = look.depth === 'always';
pins.forEach((pin, index) => {
if (culled) pin.show = engine.projector.horizonVisible(pin.horizon);
set.set(index, {
position: pin.position,
cell: 'icon',
color: pin.color,
rotation: pin.rotation,
show: pin.show,
});
});
look.count = pins.length;
set.setCount(shown ? look.count : 0);
set.commit();
}
// Hides the ground points beyond the horizon; true when any show changed.
function cull(look) {
let changed = false;
look.pins.forEach((pin, index) => {
const show = engine.projector.horizonVisible(pin.horizon);
if (show === pin.show) return;
pin.show = show;
look.set.set(index, { show });
changed = true;
});
if (changed) look.set.commit();
return changed;
}
function syncCull() {
const culled = [...looks.values()].filter(
(look) => look.set && look.depth === 'always',
);
if (!culled.length || !shown) {
removeCull?.();
removeCull = null;
return;
}
const lastCamera = geo.cartesian3.create(Number.NaN, 0, 0);
removeCull ??= engine.frames.preRender.addEventListener(() => {
const live = engine.camera.livePosition;
if (geo.cartesian3.equals(live, lastCamera)) return;
geo.cartesian3.clone(live, lastCamera);
let changed = false;
for (const look of culled) changed = cull(look) || changed;
if (changed) request();
});
}
const undrawn = Object.values(records.undrawn).reduce((n, c) => n + c, 0);
return {
report: { drawn, undrawn, kinds: { ...records.undrawn } },
setShow(visibleNow) {
visibleNow = Boolean(visibleNow);
if (destroyed || visibleNow === shown) return;
shown = visibleNow;
for (const set of groundSets) set.show(shown);
for (const line of lines) line.show(shown);
for (const layer of layers) layer.show(shown);
for (const look of looks.values()) {
look.set?.setCount(shown ? look.count : 0);
look.set?.commit();
}
syncCull();
request();
},
/** Sample the ground under the points again (a map or the terrain changed). */
async refreshGround(signal) {
const total = [...looks.values()].reduce((n, l) => n + l.pins.length, 0);
for (let from = 0; from < total; from += BATCH) {
signal?.throwIfAborted();
if (destroyed) return;
settle(heightContext(), from, from + BATCH);
if (from + BATCH < total)
await new Promise((resolve) => setTimeout(resolve, 0));
}
signal?.throwIfAborted();
if (destroyed) return;
for (const look of looks.values()) writePins(look);
request();
},
destroy() {
if (destroyed) return;
release();
request();
},
};
}
+307
View File
@@ -0,0 +1,307 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs';
import { drawImportedFile } from './geoImportDisplay.js';
import { geoJsonRecords } from './geoImportRecords.js';
// A 2D context that only records what draws.
const createCanvas = () => ({
getContext: () =>
new Proxy({}, { get: (_, key) => (key in {} ? undefined : () => {}) }),
});
for (const make of GROUND_HARNESSES) {
const displayTest = (name, run) =>
test(`${name} (${make.name})`, async (t) => {
const harness = make({
engineOptions: {
imageryFactories: {
loadImage: async () => ({ width: 1, height: 1 }),
},
},
});
const { engine, viewer } = harness;
if (viewer) {
viewer.imageryLayers = new Cesium.ImageryLayerCollection();
viewer.scene.globe.show = true;
viewer.scene.globe.getHeight = () => 0;
}
t.after(() => harness.destroy());
const pins = [];
const create = engine.movers.createSet;
engine.movers.createSet = (options) => {
const set = create(options);
const record = { options, items: [], set };
pins.push(record);
const write = set.set;
set.set = (i, fields) => {
write(i, fields);
record.items[i] = { ...record.items[i], ...fields };
};
return set;
};
await run({ ...harness, pins });
});
displayTest(
'an imported file draws through the contract and lets go',
async ({ engine, viewer }) => {
if (viewer) viewer.scene.globe.getHeight = () => 25;
else engine.fake.script('terrainHeight', 25);
const records = geoJsonRecords(
{
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { name: 'pin' },
geometry: { type: 'Point', coordinates: [1, 2] },
},
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[1, 2],
[2, 3],
],
},
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
[0, 0],
],
],
},
},
],
},
'#39d0ff',
);
records.lines.push({
positions: [
[0, 0, 100],
[1, 1, 200],
],
ground: false,
color: [1, 0, 0, 1],
widthPx: 2,
});
records.overlays.push({
url: 'data:image/png;base64,AA',
rectangle: { west: 0, south: 0, east: 1, north: 1 },
color: [1, 1, 1, 1],
rotation: 0,
});
const drawing = await drawImportedFile(engine, records, { createCanvas });
const ours = (list) =>
list.filter(({ id }) => id.startsWith('geo-import-'));
assert.deepEqual(
ours(engine.ground.list()).map(({ kind }) => kind),
['polygon', 'corridor'],
);
assert.equal(
ours(engine.movers.list()).reduce((n, set) => n + set.count, 0),
1,
);
assert.equal(ours(engine.lines.list()).length, 1);
assert.equal(ours(engine.imagery.list()).length, 1);
assert.equal(drawing.report.drawn, 5);
drawing.setShow(false);
assert.ok(ours(engine.ground.list()).every(({ shown }) => !shown));
assert.equal(ours(engine.movers.list())[0].count, 0);
assert.ok(ours(engine.lines.list()).every(({ shown }) => !shown));
assert.ok(ours(engine.imagery.list()).every(({ shown }) => !shown));
await drawing.refreshGround();
assert.equal(ours(engine.movers.list())[0].count, 0);
drawing.setShow(true);
assert.ok(ours(engine.ground.list()).every(({ shown }) => shown));
assert.ok(ours(engine.lines.list()).every(({ shown }) => shown));
assert.ok(ours(engine.imagery.list()).every(({ shown }) => shown));
assert.equal(ours(engine.movers.list())[0].count, 1);
drawing.destroy();
drawing.destroy();
assert.equal(
ours(engine.ground.list()).length +
ours(engine.movers.list()).length +
ours(engine.lines.list()).length +
ours(engine.imagery.list()).length,
0,
);
},
);
displayTest(
'pins preserve ground, relative and absolute heights across refreshes',
async ({ engine, viewer, pins }) => {
let ground = 25;
if (viewer) viewer.scene.globe.getHeight = () => ground;
else engine.fake.script('terrainHeight', () => ground);
const records = geoJsonRecords(
{
type: 'MultiPoint',
coordinates: [
[1, 2],
[1, 2, 10],
[1, 2, 500],
],
},
'#39d0ff',
);
records.points[1].altitude = 'relative';
records.points[2].altitude = 'absolute';
const drawing = await drawImportedFile(engine, records, { createCanvas });
const heights = () =>
pins.flatMap(({ items }) =>
items.map(({ position }) =>
Math.round(geo.cartographic.fromCartesian(position).height),
),
);
assert.deepEqual(heights(), [25, 35, 500]);
assert.deepEqual(
pins.map(({ options }) => options.depthTest),
['always', 'normal'],
);
assert.ok(pins.every(({ options }) => options.pickable === false));
ground = 70;
await drawing.refreshGround();
assert.deepEqual(heights(), [70, 80, 500]);
ground = undefined;
await drawing.refreshGround();
assert.deepEqual(heights(), [70, 80, 500]);
drawing.destroy();
},
);
displayTest(
'fills keep holes, raised outlines and plain overlays',
async ({ engine, set }) => {
const records = geoJsonRecords(
{
type: 'Polygon',
coordinates: [
[
[0, 0],
[2, 0],
[2, 2],
[0, 0],
],
[
[0.5, 0.5],
[1, 0.5],
[1, 1],
[0.5, 0.5],
],
],
},
'#39d0ff',
);
records.polygons[0].raised = true;
records.polygons[0].rings = records.polygons[0].rings.map((ring) =>
ring.map(([lon, lat]) => [lon, lat, 100]),
);
records.overlays.push({
url: null,
rectangle: { west: 4, south: 4, east: 5, north: 5 },
color: [0, 1, 0, 1],
});
records.undrawn = { wall: 1, extruded: 2 };
const drawing = await drawImportedFile(engine, records, {
createCanvas,
visible: false,
});
const fill = set(engine.ground.list()[0].id);
assert.equal(fill.items.length, 2);
assert.equal(fill.items[0].rings.length, 2);
assert.ok(fill.items[0].rings.flat().every((p) => p.length === 2));
assert.equal(fill.shown, false);
assert.equal(engine.lines.list().length, 2);
assert.ok(engine.lines.list().every((line) => !line.shown));
assert.deepEqual(drawing.report, {
drawn: 2,
undrawn: 3,
kinds: { wall: 1, extruded: 2 },
});
drawing.destroy();
},
);
displayTest(
'failed icon composition releases geometry already created',
async ({ engine }) => {
const records = geoJsonRecords(
{
type: 'GeometryCollection',
geometries: [
{ type: 'Point', coordinates: [1, 2] },
{
type: 'LineString',
coordinates: [
[1, 2],
[2, 3],
],
},
],
},
'#39d0ff',
);
await assert.rejects(
drawImportedFile(engine, records, {
createCanvas: () => ({ getContext: () => null }),
}),
/need a canvas/,
);
assert.equal(engine.ground.list().length, 0);
assert.equal(engine.movers.list().length, 0);
},
);
displayTest(
'ground pins follow the horizon and release frame listeners',
async ({ engine, pins }) => {
let front = true;
engine.projector.horizonVisible = () => front;
const records = geoJsonRecords(
{ type: 'Point', coordinates: [1, 2] },
'#39d0ff',
);
const drawing = await drawImportedFile(engine, records, { createCanvas });
assert.equal(pins[0].items[0].show, true);
front = false;
engine.frames.preRender.raiseEvent();
assert.equal(pins[0].items[0].show, false);
drawing.setShow(false);
front = true;
drawing.setShow(true);
engine.frames.preRender.raiseEvent();
assert.equal(pins[0].items[0].show, true);
drawing.destroy();
engine.frames.preRender.raiseEvent();
assert.equal(engine.movers.list().length, 0);
},
);
displayTest(
'an aborted ground refresh leaves the existing drawing owned',
async ({ engine }) => {
const records = geoJsonRecords(
{ type: 'Point', coordinates: [1, 2] },
'#39d0ff',
);
const drawing = await drawImportedFile(engine, records, { createCanvas });
await assert.rejects(drawing.refreshGround(AbortSignal.abort()), {
name: 'AbortError',
});
assert.equal(engine.movers.list()[0].count, 1);
drawing.destroy();
},
);
}
+410
View File
@@ -0,0 +1,410 @@
// A sanitized KML document (src/data/geoFileImport.js: no network links,
// remote images, models, descriptions or times, tracks as their recorded
// lines) read into the import's plain records (src/data/geoImportRecords.js)
// the way Cesium's KmlDataSource read it, without Cesium: the same shared,
// inline and StyleMap styles and their precedence, the same defaults (a
// yellow map pin, white lines and fills), billboards of 32 px with KML's
// distance scale and hot spot, ground lines for clamped and tessellated
// paths, lines in the air for absolute and relative ones, and ground
// overlays as image rectangles. Labels are not drawn (the import never
// drew them); walls and extruded polygons are counted in `undrawn`.
import { emptyRecords, importFeatureName } from './geoImportRecords.js';
// KmlDataSource's billboard: 32 px, scaled by camera distance.
const BILLBOARD_SIZE = 32;
const BILLBOARD_SCALE_BY_DISTANCE = Object.freeze([
2414016, 1.0, 1.6093e7, 0.1,
]);
// Its pin where no icon is given: Cesium.Color.YELLOW, drawn at 64 px.
const DEFAULT_PIN = Object.freeze({
pin: Object.freeze([1, 1, 0, 1]),
pinSize: 64,
});
const WHITE = Object.freeze([1, 1, 1, 1]);
const GEOMETRY = new Set([
'Point',
'LineString',
'LinearRing',
'Polygon',
'MultiGeometry',
'Model',
'Track',
'MultiTrack',
]);
/** Child elements of `el` with this local name (any namespace). */
const childrenNamed = (el, localName) => {
const out = [];
const kids = el?.childNodes || [];
for (let i = 0; i < kids.length; i++)
if (kids[i].nodeType === 1 && kids[i].localName === localName)
out.push(kids[i]);
return out;
};
const first = (el, localName) => childrenNamed(el, localName)[0];
/** A child's trimmed text, or undefined without the child. */
const textOf = (el, localName) => {
const child = first(el, localName);
return child ? child.textContent.trim() : undefined;
};
const numberOf = (el, localName) => {
const value = Number.parseFloat(textOf(el, localName));
return Number.isFinite(value) ? value : undefined;
};
const booleanOf = (el, localName) => {
const value = textOf(el, localName);
return value === undefined
? undefined
: value === '1' || /^true$/i.test(value);
};
/**
* A KML colour (aabbggrr) as rgba in 0..1, or undefined; `random`
* (colorMode random) draws each non-zero channel at random up to its
* value, as Cesium does, on every read.
*/
export function kmlColor(value, random = false) {
const text = String(value ?? '')
.trim()
.replace(/^#/, '');
if (!text) return undefined;
const channel = (at) => Number.parseInt(text.substring(at, at + 2), 16) / 255;
const [alpha, blue, green, red] = [0, 2, 4, 6].map(channel);
const pick = (v) => (random && v > 0 ? Math.random() * v : v);
return [pick(red), pick(green), pick(blue), alpha];
}
const colorOf = (el, localName) =>
kmlColor(textOf(el, localName), textOf(el, 'colorMode') === 'random');
/** One coordinate tuple; Google Earth reads what is missing as 0. */
function tuple(text) {
const [lon = 0, lat = 0, height = 0] = String(text ?? '')
.split(',')
.map((v) => Number.parseFloat(v))
.map((v) => (Number.isFinite(v) ? v : 0));
return [lon, lat, height];
}
const coordinates = (el) =>
el ? (el.textContent.match(/\S+/g) ?? []).map(tuple) : [];
/** Where a geometry sits, from its altitude modes (Cesium's reading). */
function altitudeOf(geometry) {
const modes = childrenNamed(geometry, 'altitudeMode');
const isGx = (el) => /google\.com\/kml\/ext/.test(el.namespaceURI || '');
const mode = modes.find((el) => !isGx(el))?.textContent.trim();
const gx = modes.find(isGx)?.textContent.trim();
if (mode === 'absolute') return 'absolute';
if (mode === 'relativeToGround') return 'relative';
if (!mode || mode === 'clampToGround')
return gx === 'relativeToSeaFloor' && !mode ? 'relative' : 'ground';
return gx === 'relativeToSeaFloor' ? 'relative' : 'ground';
}
// A style as KmlDataSource builds it on an entity: `icon` (its billboard),
// `line` (its polyline) and `poly` (its polygon); a property left undefined
// is filled by a merged style.
function applyStyle(styleEl, target) {
for (const node of childrenNamed(styleEl, 'IconStyle')) {
const iconEl = first(node, 'Icon');
const href = textOf(iconEl, 'href');
const icon = (target.icon ??= {});
// Blank <Icon> tags mean no icon at all.
icon.image = href ? { url: href } : iconEl ? false : undefined;
icon.scale = numberOf(node, 'scale');
icon.color = colorOf(node, 'color');
const region = ['x', 'y', 'w', 'h'].map((key) => numberOf(iconEl, key));
if (region.some((v) => v !== undefined))
icon.subRegion = region.map((v) => v ?? 0);
const heading = numberOf(node, 'heading');
// Google Earth reads a heading of 0 as none.
if (heading !== undefined && heading !== 0)
icon.rotation = (-heading * Math.PI) / 180;
const offset = hotSpotOffset(first(node, 'hotSpot'), icon.scale ?? 1);
if (offset) icon.offsetPx = offset;
}
for (const node of childrenNamed(styleEl, 'LineStyle'))
target.line = {
width: numberOf(node, 'width'),
color: colorOf(node, 'color'),
};
for (const node of childrenNamed(styleEl, 'PolyStyle')) {
const poly = (target.poly ??= { outline: true, outlineColor: WHITE });
poly.color = colorOf(node, 'color') ?? poly.color;
poly.fill = booleanOf(node, 'fill') ?? poly.fill;
poly.outline = booleanOf(node, 'outline') ?? poly.outline;
}
}
/** A KML hot spot as Cesium's billboard pixel offset (+y down), or null. */
function hotSpotOffset(el, scale) {
if (!el) return null;
const read = (name) => {
const value = Number.parseFloat(el.getAttribute(name));
return Number.isFinite(value) ? value : undefined;
};
const x = read('x');
const y = read('y');
let dx;
let dy;
if (x !== undefined) {
const unit = el.getAttribute('xunits');
if (unit === 'pixels') dx = -x * scale;
else if (unit === 'insetPixels') dx = (x - BILLBOARD_SIZE) * scale;
else if (unit === 'fraction') dx = -x * BILLBOARD_SIZE * scale;
dx = (dx ?? Number.NaN) + BILLBOARD_SIZE * 0.5 * scale;
}
if (y !== undefined) {
const unit = el.getAttribute('yunits');
if (unit === 'pixels') dy = y * scale;
else if (unit === 'insetPixels') dy = (-y + BILLBOARD_SIZE) * scale;
else if (unit === 'fraction') dy = y * BILLBOARD_SIZE * scale;
dy = (dy ?? Number.NaN) - BILLBOARD_SIZE * 0.5 * scale;
}
if (dx === undefined && dy === undefined) return null;
const finite = (v) => (Number.isFinite(v) ? v : 0);
return [finite(dx), finite(dy)];
}
const cloneStyle = (style) => ({
icon: style.icon && { ...style.icon },
line: style.line && { ...style.line },
poly: style.poly && { ...style.poly },
});
/** Entity.merge: what `target` leaves undefined comes from `source`. */
function mergeStyle(target, source) {
if (!source) return target;
for (const key of ['icon', 'line', 'poly']) {
if (!source[key]) continue;
if (!target[key]) target[key] = { ...source[key] };
else
for (const [name, value] of Object.entries(source[key]))
target[key][name] ??= value;
}
return target;
}
/** The document's shared styles by '#id', as KmlDataSource collects them. */
function sharedStyles(doc) {
const styles = new Map();
const all = [...doc.getElementsByTagName('*')];
for (const el of all) {
if (el.localName !== 'Style') continue;
const id = el.getAttribute('id');
if (id === null || styles.has(`#${id}`)) continue;
const style = {};
applyStyle(el, style);
styles.set(`#${id}`, style);
}
for (const el of all) {
if (el.localName !== 'StyleMap') continue;
const id = el.getAttribute('id');
if (id === null) continue;
for (const pair of childrenNamed(el, 'Pair')) {
if (textOf(pair, 'key') !== 'normal' || styles.has(`#${id}`)) continue;
const style = {};
const url = textOf(pair, 'styleUrl');
if (url !== undefined)
mergeStyle(style, styles.get(url.startsWith('#') ? url : `#${url}`));
else {
const inline = first(pair, 'Style');
if (inline) applyStyle(inline, style);
}
styles.set(`#${id}`, style);
}
}
return styles;
}
/** A placemark's final style: its last inline style, then its styleUrl. */
function placemarkStyle(placemark, styles) {
const lookup = (url) => styles.get(url) ?? styles.get(`#${url}`);
const result = {};
const inline = [...(placemark.childNodes || [])]
.filter(
(el) =>
el.nodeType === 1 &&
(el.localName === 'Style' || el.localName === 'StyleMap'),
)
.at(-1);
if (inline?.localName === 'Style') applyStyle(inline, result);
else if (inline)
for (const pair of childrenNamed(inline, 'Pair')) {
if (textOf(pair, 'key') !== 'normal') continue;
const url = textOf(pair, 'styleUrl');
if (url !== undefined) mergeStyle(result, lookup(url));
else {
const style = first(pair, 'Style');
if (style) applyStyle(style, result);
}
}
const external = textOf(placemark, 'styleUrl');
if (external !== undefined) mergeStyle(result, lookup(external));
return result;
}
/** The polyline Cesium draws for a style: its colour (white) and width (1). */
const lineLook = (style) => ({
color: style.line?.color ?? WHITE,
widthPx: style.line?.width ?? 1,
});
function readGeometry(geometry, style, name, records) {
const kind = geometry.localName;
if (kind === 'MultiGeometry') {
for (const child of geometry.childNodes || [])
if (child.nodeType === 1 && GEOMETRY.has(child.localName))
readGeometry(child, style, name, records);
return;
}
const altitude = altitudeOf(geometry);
const extrude = booleanOf(geometry, 'extrude') === true;
if (kind === 'Point') {
const [position = [0, 0, 0]] = coordinates(first(geometry, 'coordinates'));
const icon = style.icon ?? {};
if (icon.image !== false)
records.points.push({
name,
position,
altitude,
icon: {
image: icon.image || DEFAULT_PIN,
widthPx: BILLBOARD_SIZE,
heightPx: BILLBOARD_SIZE,
scale: icon.scale,
color: icon.color,
offsetPx: icon.offsetPx,
subRegion: icon.subRegion,
rotation: icon.rotation,
scaleByDistance: BILLBOARD_SCALE_BY_DISTANCE,
},
});
// A drop line from an extruded point down to the ellipsoid.
if (extrude && altitude !== 'ground')
records.lines.push({
name,
positions: [position, [position[0], position[1], 0]],
ground: false,
...lineLook(style),
});
return;
}
if (kind === 'LineString' || kind === 'LinearRing') {
const positions = coordinates(first(geometry, 'coordinates'));
if (positions.length < 2) return;
const tessellate = booleanOf(geometry, 'tessellate') === true;
if (altitude !== 'ground' && extrude) {
// A wall; its outline's top edge draws as the path.
records.undrawn.wall = (records.undrawn.wall ?? 0) + 1;
records.lines.push({
name,
positions,
ground: false,
...lineLook(style),
});
} else if (altitude === 'ground' && tessellate)
records.lines.push({ name, positions, ground: true, ...lineLook(style) });
else
records.lines.push({
name,
positions:
altitude === 'ground'
? positions.map(([lon, lat]) => [lon, lat, 0])
: positions,
ground: false,
...lineLook(style),
});
return;
}
if (kind === 'Polygon') {
const ringOf = (boundary) =>
coordinates(first(first(boundary, 'LinearRing'), 'coordinates'));
const outer = ringOf(first(geometry, 'outerBoundaryIs'));
if (!outer.length) return;
const holes = childrenNamed(geometry, 'innerBoundaryIs')
.flatMap((boundary) =>
childrenNamed(boundary, 'LinearRing').map((ring) =>
coordinates(first(ring, 'coordinates')),
),
)
.filter((ring) => ring.length);
const poly = style.poly ?? { outline: true, outlineColor: WHITE };
const raised = altitude !== 'ground';
if (raised && extrude)
records.undrawn.extruded = (records.undrawn.extruded ?? 0) + 1;
records.polygons.push({
name,
rings: [outer, ...holes],
raised,
fill: poly.fill === false ? null : (poly.color ?? WHITE),
outline:
poly.outline === false
? null
: {
color: style.line
? (style.line.color ?? WHITE)
: (poly.outlineColor ?? WHITE),
widthPx: style.line?.width ?? 1,
},
});
}
// A Model or a Track left by the sanitizer has nothing to draw.
}
function readGroundOverlay(el, records) {
const name = importFeatureName(textOf(el, 'name'));
const color = colorOf(el, 'color') ?? WHITE;
const quad = first(el, 'LatLonQuad');
if (quad) {
records.undrawn.latLonQuad = (records.undrawn.latLonQuad ?? 0) + 1;
return;
}
const box = first(el, 'LatLonBox');
if (!box) return;
// Cesium's negativePiToPi and latitude clamp.
const lon = (v = 0) =>
v >= -180 && v <= 180 ? v : ((((v + 180) % 360) + 360) % 360) - 180;
const lat = (v = 0) => Math.max(-90, Math.min(90, v));
const rectangle = {
west: lon(numberOf(box, 'west')),
south: lat(numberOf(box, 'south')),
east: lon(numberOf(box, 'east')),
north: lat(numberOf(box, 'north')),
};
if (!(rectangle.north > rectangle.south)) return;
const url = textOf(first(el, 'Icon'), 'href') || null;
records.overlays.push({
name,
url,
rectangle,
color,
rotation: numberOf(box, 'rotation') ?? 0,
});
}
/**
* Read a sanitized KML document into records, placemarks and ground
* overlays in document order.
* @param {Document} doc
* @returns {import('./geoImportRecords.js').ImportedRecords}
*/
export function readKmlRecords(doc) {
const records = emptyRecords();
const styles = sharedStyles(doc);
for (const el of doc.getElementsByTagName('*')) {
if (el.localName === 'GroundOverlay') {
readGroundOverlay(el, records);
continue;
}
if (el.localName !== 'Placemark') continue;
const name = importFeatureName(textOf(el, 'name'));
const style = cloneStyle(placemarkStyle(el, styles));
// Only a placemark's first geometry draws.
const geometry = [...(el.childNodes || [])].find(
(child) => child.nodeType === 1 && GEOMETRY.has(child.localName),
);
if (geometry) readGeometry(geometry, style, name, records);
}
return records;
}
+378
View File
@@ -0,0 +1,378 @@
// The KML reader against Cesium's KmlDataSource, the reference it replaces:
// the same sanitized documents read by both give the same pins, lines,
// polygons and overlays with the same styles. Cesium stays a test
// dependency here only.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
deduplicateKmlStyles,
loadGeoFile,
parseXml,
sanitizeKmlDocument,
} from './geoFileImport.js';
import { kmlColor, readKmlRecords } from './geoImportKml.js';
// happy-dom's getElementsByTagNameNS('*', name) finds nothing in a parsed
// XML document; KmlDataSource queries every element through it.
function byNamespace(namespace, localName) {
return [...this.getElementsByTagName('*')].filter(
(el) =>
(localName === '*' || el.localName === localName) &&
(namespace === '*' || el.namespaceURI === namespace),
);
}
const probe = new DOMParser().parseFromString('<a><b/></a>', 'application/xml');
for (const node of [probe, probe.documentElement])
for (let p = Object.getPrototypeOf(node); p; p = Object.getPrototypeOf(p))
if (Object.hasOwn(p, 'getElementsByTagNameNS')) {
p.getElementsByTagNameNS = byNamespace;
break;
}
const TRAP = 'gev-qa-trap.invalid';
const PNG =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
const kml = (body) => `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
<Document>${body}</Document></kml>`;
// scripts/qa-geo-import.mjs's KML, with a packed icon and overlay as in its KMZ.
const QA = kml(`
<Style id="s"><IconStyle><Icon><href>${PNG}</href></Icon></IconStyle></Style>
<Style id="palette"><IconStyle><Icon><href>root://icons/palette-4.png</href><gx:x>32</gx:x><gx:y>64</gx:y></Icon></IconStyle></Style>
<NetworkLink><Link><href>https://${TRAP}/link.kml</href></Link></NetworkLink>
<Placemark><name>QA KML pin</name><styleUrl>#s</styleUrl>
<description>&lt;img src="https://${TRAP}/balloon.png"&gt;</description>
<TimeSpan><begin>2019-01-01T00:00:00Z</begin><end>2019-01-02T00:00:00Z</end></TimeSpan>
<Point><coordinates>-122.47,37.77,0</coordinates></Point></Placemark>
<Placemark><name>QA palette pin</name><styleUrl>#palette</styleUrl>
<Point><coordinates>-122.48,37.771,0</coordinates></Point></Placemark>
<Placemark><name>QA moving</name>
<gx:Track><when>2019-01-01T00:00:00Z</when><when>2019-01-01T01:00:00Z</when>
<gx:coord>-122.49 37.767 0</gx:coord><gx:coord>-122.475 37.772 0</gx:coord></gx:Track></Placemark>
<Placemark><name>QA split tracks</name><gx:MultiTrack>
<gx:Track><gx:coord>-122.50 37.767 0</gx:coord><gx:coord>-122.49 37.768 0</gx:coord></gx:Track>
<gx:Track><gx:coord>-122.47 37.771 0</gx:coord><gx:coord>-122.46 37.772 0</gx:coord></gx:Track>
</gx:MultiTrack></Placemark>
<Placemark><name>QA remote style</name><styleUrl>https://${TRAP}/styles.kml#x</styleUrl>
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
<GroundOverlay><Icon><href>${PNG}</href></Icon>
<LatLonBox><north>37.77</north><south>37.76</south><east>-122.46</east><west>-122.47</west></LatLonBox></GroundOverlay>`);
// Styles and geometries the import keeps, each the way KML allows.
const STYLED = kml(`
<Style id="red"><LineStyle><color>ff0000ff</color><width>4</width></LineStyle>
<PolyStyle><color>7f00ff00</color></PolyStyle></Style>
<Style id="pin"><IconStyle><scale>1.5</scale><color>ff00ffff</color><heading>30</heading>
<Icon><href>${PNG}</href><gx:x>0</gx:x><gx:y>0</gx:y><gx:w>1</gx:w><gx:h>1</gx:h></Icon>
<hotSpot x="20" y="2" xunits="pixels" yunits="pixels"/></IconStyle></Style>
<Style id="frac"><IconStyle><hotSpot x="0.5" y="0" xunits="fraction" yunits="fraction"/></IconStyle></Style>
<Style id="inset"><IconStyle><scale>2</scale><hotSpot x="4" y="6" xunits="insetPixels" yunits="insetPixels"/></IconStyle></Style>
<Style id="none"><IconStyle><Icon></Icon></IconStyle></Style>
<Style id="hollow"><PolyStyle><fill>0</fill><outline>1</outline></PolyStyle><LineStyle><color>ffff0000</color><width>3</width></LineStyle></Style>
<StyleMap id="map"><Pair><key>normal</key><styleUrl>#red</styleUrl></Pair>
<Pair><key>highlight</key><styleUrl>#pin</styleUrl></Pair></StyleMap>
<StyleMap id="inlineMap"><Pair><key>normal</key><Style><LineStyle><color>ff00ff00</color></LineStyle></Style></Pair></StyleMap>
<Placemark><name>default pin</name><Point><coordinates>1,1</coordinates></Point></Placemark>
<Placemark><name>styled pin</name><styleUrl>#pin</styleUrl><Point><coordinates>1,2,50</coordinates></Point></Placemark>
<Placemark><name>fraction</name><styleUrl>#frac</styleUrl><Point><altitudeMode>absolute</altitudeMode><coordinates>1,3,500</coordinates></Point></Placemark>
<Placemark><name>inset</name><styleUrl>#inset</styleUrl><Point><altitudeMode>relativeToGround</altitudeMode><extrude>1</extrude><coordinates>1,4,80</coordinates></Point></Placemark>
<Placemark><name>hidden icon</name><styleUrl>#none</styleUrl><Point><coordinates>1,5</coordinates></Point></Placemark>
<Placemark><name>mapped line</name><styleUrl>#map</styleUrl><LineString><coordinates>0,0 1,1 2,1</coordinates></LineString></Placemark>
<Placemark><name>inline map line</name><styleUrl>#inlineMap</styleUrl><LineString><coordinates>0,0 1,0</coordinates></LineString></Placemark>
<Placemark><name>override</name><styleUrl>#red</styleUrl><Style><LineStyle><width>9</width></LineStyle></Style>
<LineString><coordinates>0,1 1,2</coordinates></LineString></Placemark>
<Placemark><name>air line</name><styleUrl>#red</styleUrl><LineString><altitudeMode>absolute</altitudeMode><coordinates>0,2,100 1,3,200</coordinates></LineString></Placemark>
<Placemark><name>relative line</name><LineString><tessellate>0</tessellate><altitudeMode>relativeToGround</altitudeMode><coordinates>0,3,10 1,4,20</coordinates></LineString></Placemark>
<Placemark><name>wall</name><styleUrl>#red</styleUrl><LineString><extrude>1</extrude><altitudeMode>absolute</altitudeMode><coordinates>0,4,100 1,5,100</coordinates></LineString></Placemark>
<Placemark><name>ring</name><LinearRing><coordinates>0,0 1,0 1,1 0,0</coordinates></LinearRing></Placemark>
<Placemark><name>filled</name><styleUrl>#red</styleUrl><Polygon>
<outerBoundaryIs><LinearRing><coordinates>0,0 2,0 2,2 0,2 0,0</coordinates></LinearRing></outerBoundaryIs>
<innerBoundaryIs><LinearRing><coordinates>0.5,0.5 1,0.5 1,1 0.5,0.5</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
<Placemark><name>hollow raised</name><styleUrl>#hollow</styleUrl><Polygon><altitudeMode>absolute</altitudeMode>
<outerBoundaryIs><LinearRing><coordinates>3,0,10 4,0,10 4,1,10 3,0,10</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
<Placemark><name>plain</name><Polygon><outerBoundaryIs><LinearRing><coordinates>5,0 6,0 6,1 5,0</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
<Placemark><name>multi</name><styleUrl>#red</styleUrl><MultiGeometry>
<Point><coordinates>7,7</coordinates></Point>
<LineString><coordinates>7,7 8,8</coordinates></LineString>
<MultiGeometry><Polygon><outerBoundaryIs><LinearRing><coordinates>7,0 8,0 8,1 7,0</coordinates></LinearRing></outerBoundaryIs></Polygon></MultiGeometry>
</MultiGeometry></Placemark>
<Folder><Placemark><name>in folder</name><styleUrl>#red</styleUrl><Point><coordinates>9,9</coordinates></Point></Placemark></Folder>
<GroundOverlay><color>80ffffff</color><Icon><href>${PNG}</href></Icon>
<LatLonBox><north>11</north><south>10</south><east>-179</east><west>179</west><rotation>15</rotation></LatLonBox></GroundOverlay>
<GroundOverlay><color>ff0000ff</color><LatLonBox><north>13</north><south>12</south><east>13</east><west>12</west></LatLonBox></GroundOverlay>
<Style id="a"><LineStyle><color>ff123456</color></LineStyle></Style>
<Style id="b"><LineStyle><color>ff123456</color></LineStyle></Style>
<Placemark><name>deduplicated a</name><styleUrl>#a</styleUrl><LineString><coordinates>3,3 4,4</coordinates></LineString></Placemark>
<Placemark><name>deduplicated b</name><styleUrl>#b</styleUrl><LineString><coordinates>4,4 5,5</coordinates></LineString></Placemark>`);
/** A document as the import prepares it for reading. */
function prepared(text) {
const doc = parseXml(text, 'KML');
sanitizeKmlDocument(doc);
deduplicateKmlStyles(doc);
return doc;
}
// To 1e-6, and -0 as 0.
const round = (v) =>
v === undefined ? undefined : Math.round(v * 1e6) / 1e6 + 0;
const rgba = (c) => c && [c.red, c.green, c.blue, c.alpha].map(round);
const lonLatH = (cartesian) => {
const c = Cesium.Cartographic.fromCartesian(cartesian);
return [
Cesium.Math.toDegrees(c.longitude),
Cesium.Math.toDegrees(c.latitude),
c.height,
].map((v) => Math.round(v * 1e4) / 1e4 + 0);
};
const roundPosition = (p) => p.map((v) => Math.round(v * 1e4) / 1e4 + 0);
const HEIGHT_MODE = {
[Cesium.HeightReference.CLAMP_TO_GROUND]: 'ground',
[Cesium.HeightReference.RELATIVE_TO_GROUND]: 'relative',
[Cesium.HeightReference.NONE]: 'absolute',
};
/** What Cesium's KmlDataSource draws of a document, as comparable data. */
async function cesiumReading(text) {
const previous = Cesium.PinBuilder.prototype.fromColor;
Cesium.PinBuilder.prototype.fromColor = (color, size) => ({
pin: rgba(color),
pinSize: size,
});
try {
const ds = await Cesium.KmlDataSource.load(prepared(text), {
clampToGround: true,
});
const now = Cesium.JulianDate.now();
const value = (property) => property?.getValue?.(now);
const out = { points: [], lines: [], polygons: [], overlays: [], walls: 0 };
for (const entity of ds.entities.values) {
const billboard = entity.billboard;
const image = value(billboard?.image);
if (billboard && entity.position && image) {
const offset = value(billboard.pixelOffset);
const rotation = value(billboard.rotation);
const region = value(billboard.imageSubRegion);
out.points.push({
name: entity.name,
position: lonLatH(value(entity.position)),
altitude: HEIGHT_MODE[value(billboard.heightReference)],
image: image.pin ? image : { url: image.url ?? String(image) },
scale: round(value(billboard.scale) ?? 1),
color: rgba(value(billboard.color)) ?? [1, 1, 1, 1],
offset: offset ? [offset.x, offset.y].map(round) : [0, 0],
rotation: round(rotation ?? 0),
subRegion: region
? [region.x, region.y, region.width, region.height]
: undefined,
size: [value(billboard.width), value(billboard.height)],
});
}
if (entity.polyline) {
const ground = value(entity.polyline.clampToGround) === true;
out.lines.push({
name: entity.name,
positions: value(entity.polyline.positions).map((p) => {
const [lon, lat, h] = lonLatH(p);
return ground ? [lon, lat] : [lon, lat, h];
}),
ground,
color: rgba(value(entity.polyline.material)?.color) ??
rgba(entity.polyline.material?.color?.getValue(now)) ?? [
1, 1, 1, 1,
],
width: value(entity.polyline.width) ?? 1,
});
}
if (entity.wall) out.walls += 1;
if (entity.polygon) {
const polygon = entity.polygon;
const hierarchy = value(polygon.hierarchy);
const raised = value(polygon.perPositionHeight) === true;
const ring = (positions) =>
positions.map((p) => {
const [lon, lat, h] = lonLatH(p);
return raised ? [lon, lat, h] : [lon, lat];
});
const fill = value(polygon.fill) ?? true;
const outline = value(polygon.outline) ?? false;
out.polygons.push({
name: entity.name,
rings: [
ring(hierarchy.positions),
...hierarchy.holes.map((h) => ring(h.positions)),
],
raised,
fill: fill
? (rgba(polygon.material?.color?.getValue(now)) ?? [1, 1, 1, 1])
: null,
outline: outline
? {
color: rgba(value(polygon.outlineColor)) ?? [1, 1, 1, 1],
widthPx: value(polygon.outlineWidth) ?? 1,
}
: null,
});
}
if (entity.rectangle) {
const r = value(entity.rectangle.coordinates);
const material = entity.rectangle.material;
const url = material?.image?.getValue(now);
out.overlays.push({
rectangle: [r.west, r.south, r.east, r.north].map((v) =>
round(Cesium.Math.toDegrees(v)),
),
url: url ? (url.url ?? String(url)) : null,
color: rgba(material?.color?.getValue(now)) ??
rgba(material?.getValue?.(now)?.color) ?? [1, 1, 1, 1],
rotation: round(
Cesium.Math.toDegrees(value(entity.rectangle.rotation) ?? 0),
),
});
}
}
return out;
} finally {
Cesium.PinBuilder.prototype.fromColor = previous;
}
}
/** The reader's records of a document, in the same comparable form. */
function ourReading(text) {
const records = readKmlRecords(prepared(text));
return {
points: records.points.map(({ name, position, altitude, icon }) => ({
name,
position: roundPosition(position),
altitude,
image: icon.image.pin
? { pin: icon.image.pin.map(round), pinSize: icon.image.pinSize }
: icon.image,
scale: round(icon.scale ?? 1),
color: (icon.color ?? [1, 1, 1, 1]).map(round),
offset: (icon.offsetPx ?? [0, 0]).map(round),
rotation: round(icon.rotation ?? 0),
subRegion: icon.subRegion,
size: [icon.widthPx, icon.heightPx],
})),
lines: records.lines.map(({ name, positions, ground, color, widthPx }) => ({
name,
positions: positions.map((p) =>
ground ? roundPosition(p).slice(0, 2) : roundPosition(p),
),
ground,
color: color.map(round),
width: widthPx,
})),
polygons: records.polygons.map(
({ name, rings, raised, fill, outline }) => ({
name,
rings: rings.map((ring) =>
ring.map((p) =>
raised ? roundPosition(p) : roundPosition(p).slice(0, 2),
),
),
raised,
fill: fill && fill.map(round),
outline: outline && {
color: outline.color.map(round),
widthPx: outline.widthPx,
},
}),
),
overlays: records.overlays.map(
({ rectangle: r, url, color, rotation }) => ({
rectangle: [r.west, r.south, r.east, r.north].map(round),
url,
// Cesium's transparent image material caps the alpha at 0.99, as the
// display's imagery alpha does.
color: (url
? [...color.slice(0, 3), Math.min(0.99, color[3])]
: color
).map(round),
rotation: round(rotation),
}),
),
walls: records.undrawn.wall ?? 0,
};
}
for (const [name, text] of [
['the QA file', QA],
['styles, altitudes, multigeometries and overlays', STYLED],
]) {
test(`KML records match Cesium's reading: ${name}`, async () => {
const cesium = await cesiumReading(text);
const ours = ourReading(text);
assert.deepEqual(ours.points, cesium.points);
// Cesium draws a wall's outline where the reader draws its top edge as
// a line; every other line is the same.
assert.deepEqual(
ours.lines.filter((line) => line.name !== 'wall'),
cesium.lines,
);
assert.equal(ours.walls, cesium.walls);
assert.deepEqual(ours.polygons, cesium.polygons);
assert.deepEqual(ours.overlays, cesium.overlays);
});
}
test('the QA file reads to pins, ground lines and a packed overlay, with nothing remote', () => {
const records = readKmlRecords(prepared(QA));
assert.deepEqual(
records.points.map(({ name, icon }) => [name, Boolean(icon.image.pin)]),
[
['QA KML pin', false],
// A blocked palette icon falls back to Cesium's yellow pin.
['QA palette pin', true],
],
);
assert.deepEqual(
records.lines.map(({ name, ground, positions }) => [
name,
ground,
positions.length,
]),
[
['QA moving', true, 2],
['QA split tracks', true, 2],
['QA split tracks', true, 2],
['QA remote style', true, 2],
],
);
assert.equal(records.overlays.length, 1);
assert.ok(!JSON.stringify(records).includes(TRAP));
// No time survives: a dated placemark draws for all time.
assert.ok(!/2019/.test(JSON.stringify(records)));
});
test('extruded polygons and quads are counted as undrawn', () => {
const records = readKmlRecords(
prepared(
kml(`<Placemark><Polygon><extrude>1</extrude><altitudeMode>absolute</altitudeMode>
<outerBoundaryIs><LinearRing><coordinates>0,0,9 1,0,9 1,1,9 0,0,9</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
<GroundOverlay><Icon><href>${PNG}</href></Icon><gx:LatLonQuad><coordinates>0,0 1,0 1,1 0,1</coordinates></gx:LatLonQuad></GroundOverlay>`),
),
);
assert.deepEqual(records.undrawn, { extruded: 1, latLonQuad: 1 });
assert.equal(records.polygons[0].raised, true);
});
test('KML colours are aabbggrr; a random colour stays within its channels', () => {
assert.deepEqual(kmlColor('7f00ff00'), [0, 1, 0, 127 / 255]);
assert.equal(kmlColor(' '), undefined);
const [red, green, blue, alpha] = kmlColor('ff0000ff', true);
assert.ok(red >= 0 && red <= 1 && green === 0 && blue === 0 && alpha === 1);
});
test('a KML file loads to records with its name, count and outside content removed', async () => {
const loaded = await loadGeoFile(new File([QA], 'remote-bits.kml'));
assert.equal(loaded.format, 'kml');
assert.equal(loaded.entityCount, 7);
assert.ok(loaded.removed > 0);
assert.equal(loaded.records.points[0].name, 'QA KML pin');
loaded.revoke();
});
+133 -122
View File
@@ -1,128 +1,139 @@
import * as Cesium from 'cesium';
// The images of imported points (UTILITIES ▸ Import), without Cesium: the
// map pin Cesium's PinBuilder drew for a GeoJSON point or a KML placemark
// without an icon, and each point's look composed onto one square sprite
// whose centre is the point (src/data/geoImportDisplay.js), so the pin's
// tip, a KML hot spot or an icon's sub-region lands where Cesium put it.
const STYLE = [
'image',
'scale',
'rotation',
'alignedAxis',
'horizontalOrigin',
'verticalOrigin',
'color',
'eyeOffset',
'pixelOffset',
'width',
'height',
'scaleByDistance',
'translucencyByDistance',
'pixelOffsetScaleByDistance',
'distanceDisplayCondition',
'sizeInMeters',
'disableDepthTestDistance',
'imageSubRegion',
];
/** Cesium.Color's CSS form of rgba in 0..1 (its float-to-byte conversion). */
export function cssOf([red, green, blue, alpha = 1]) {
const byte = (v) => (v === 1 ? 255 : (v * 256) | 0);
const rgb = `${byte(red)},${byte(green)},${byte(blue)}`;
return alpha === 1 ? `rgb(${rgb})` : `rgba(${rgb},${alpha})`;
}
/** Own static imported pins without a per-frame Entity visualizer walk. */
export function createImportedPins(dataSource, viewer) {
class ImportedBillboards extends Cesium.BillboardCollection {
update(frameState) {
// Height queries should find the map, never another imported marker.
// Ordinary click picks and rendered screenshots still include the pins.
if (frameState.passes.pick && frameState.passes.offscreen) return;
super.update(frameState);
}
}
/** Cesium.Color#brighten. */
const brighten = ([red, green, blue, alpha = 1], magnitude) => {
const keep = 1 - magnitude;
return [
1 - (1 - red) * keep,
1 - (1 - green) * keep,
1 - (1 - blue) * keep,
alpha,
];
};
/**
* Draw Cesium's map pin (PinBuilder's drawPin, from its Assets/Textures/
* pin.svg) of `color` (rgba in 0..1) into a `size` square at the context's
* origin.
*/
export function drawPin(context, color, size) {
context.save();
context.scale(size / 24, size / 24);
context.fillStyle = cssOf(color);
context.strokeStyle = cssOf(brighten(color, 0.6));
context.lineWidth = 0.846;
context.beginPath();
context.moveTo(6.72, 0.422);
context.lineTo(17.28, 0.422);
context.bezierCurveTo(18.553, 0.422, 19.577, 1.758, 19.577, 3.415);
context.lineTo(19.577, 10.973);
context.bezierCurveTo(19.577, 12.63, 18.553, 13.966, 17.282, 13.966);
context.lineTo(14.386, 14.008);
context.lineTo(11.826, 23.578);
context.lineTo(9.614, 14.008);
context.lineTo(6.719, 13.965);
context.bezierCurveTo(5.446, 13.983, 4.422, 12.629, 4.422, 10.972);
context.lineTo(4.422, 3.416);
context.bezierCurveTo(4.423, 1.76, 5.447, 0.423, 6.718, 0.423);
context.closePath();
context.fill();
context.stroke();
context.restore();
}
/**
* Where an icon draws around its point, in CSS pixels, as Cesium placed the
* billboard: `width` × `height` (the icon's own size when the record gives
* none) times `scale`, centred on the point (`origin: 'center'`) or standing
* on it (`'bottom'`), then moved by `offsetPx` (+y down). Returns the box's
* centre ([x, y] from the point) and size, and the side of the square
* sprite centred on the point that holds it.
* @param {object} icon An imported point's icon (src/data/geoImportRecords.js).
* @param {{ width: number, height: number }} [natural] The image's own size.
*/
export function iconBox(icon, natural) {
const pinSize = icon.image?.pin ? icon.image.pinSize : 0;
const ownWidth = icon.subRegion?.[2] || natural?.width || pinSize || 32;
const ownHeight = icon.subRegion?.[3] || natural?.height || pinSize || 32;
const scale = icon.scale ?? 1;
const width = (icon.widthPx ?? ownWidth) * scale;
const height = (icon.heightPx ?? ownHeight) * scale;
const [dx, dy] = icon.offsetPx ?? [0, 0];
const center = [dx, dy - (icon.origin === 'bottom' ? height / 2 : 0)];
const side =
2 *
Math.max(
Math.abs(center[0]) + width / 2,
Math.abs(center[1]) + height / 2,
0.5,
);
return { center, width, height, side };
}
const all = dataSource.entities.values.slice();
const now = Cesium.JulianDate.now();
const pins = all.filter((entity) => entity.billboard && entity.position);
if (!pins.length) return { mount() {}, sync() {}, destroy() {} };
const collection = new ImportedBillboards({ scene: viewer.scene });
const billboards = [];
try {
for (const entity of pins) {
const position = entity.position.getValue(now);
if (
!position ||
![position.x, position.y, position.z].every(Number.isFinite)
)
throw new Error('Invalid imported point position.');
const options = {
id: entity,
position,
show:
entity.isShowing && entity.billboard.show?.getValue(now) !== false,
heightReference: Cesium.HeightReference.NONE,
};
for (const key of STYLE) {
const value = entity.billboard[key]?.getValue(now);
if (value !== undefined) options[key] = value;
/**
* Compose one icon onto a square canvas centred on its point (see iconBox).
* `image` is the decoded image of a `url` icon (ignored for a pin);
* `ratio` canvas pixels per CSS pixel. Returns the canvas and the sprite's
* side in CSS pixels.
*/
export function composeIcon(icon, { image, createCanvas, ratio = 1 } = {}) {
const natural = image
? {
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
}
billboards.push(collection.add(options));
}
} catch (error) {
collection.destroy();
throw error;
: undefined;
const box = iconBox(icon, natural);
// At least one canvas pixel per icon pixel, at most 512 a side.
const k = Math.min(ratio, 512 / box.side);
const canvas = createCanvas();
canvas.width = Math.max(1, Math.ceil(box.side * k));
canvas.height = canvas.width;
const context = canvas.getContext('2d');
if (!context) throw new Error('Imported icons need a canvas');
const x = (box.side / 2 + box.center[0] - box.width / 2) * k;
const y = (box.side / 2 + box.center[1] - box.height / 2) * k;
if (icon.image?.pin) {
// PinBuilder draws the pin at its own size; the billboard then
// stretches that canvas over the box.
context.translate(x, y);
context.scale(
box.width / icon.image.pinSize,
box.height / icon.image.pinSize,
);
context.scale(k, k);
drawPin(context, icon.image.pin, icon.image.pinSize);
} else if (image) {
const [sx, sy, sw, sh] = icon.subRegion ?? [
0,
0,
natural.width,
natural.height,
];
// Cesium's imageSubRegion counts y up from the image's bottom.
const top = icon.subRegion ? natural.height - sy - sh : sy;
context.drawImage(
image,
sx,
top,
sw,
sh,
x,
y,
box.width * k,
box.height * k,
);
}
// Rebuild once before the source is added to DataSourceDisplay. Removing
// one entity at a time would itself make this transition quadratic.
dataSource.entities.suspendEvents();
try {
dataSource.entities.removeAll();
for (const entity of all)
if (!entity.billboard || !entity.position)
dataSource.entities.add(entity);
} finally {
dataSource.entities.resumeEvents();
}
Object.defineProperty(dataSource, 'importedEntities', {
value: all,
configurable: true,
});
Object.defineProperty(dataSource, 'importedBillboards', {
value: collection,
configurable: true,
});
let show = dataSource.show,
mounted = false,
destroyed = false;
Object.defineProperty(dataSource, 'show', {
configurable: true,
get: () => show,
set(value) {
show = Boolean(value);
dataSource.entities.show = show;
collection.show = show;
},
});
collection.show = show;
return {
mount() {
if (destroyed || mounted) return;
viewer.scene.primitives?.add(collection);
mounted = true;
},
sync() {
if (destroyed) return;
for (let i = 0; i < pins.length; i++) {
billboards[i].position = pins[i].position.getValue(now);
billboards[i].show =
pins[i].isShowing && pins[i].billboard.show?.getValue(now) !== false;
}
viewer.scene.requestRender?.();
},
destroy() {
if (destroyed) return;
destroyed = true;
if (viewer.trackedEntity && pins.includes(viewer.trackedEntity))
viewer.trackedEntity = undefined;
if (viewer.selectedEntity && pins.includes(viewer.selectedEntity))
viewer.selectedEntity = undefined;
if (mounted && !viewer.scene.primitives?.isDestroyed?.())
viewer.scene.primitives?.remove(collection);
if (!collection.isDestroyed()) collection.destroy();
all.length = 0;
pins.length = 0;
billboards.length = 0;
},
};
return { canvas, side: box.side };
}
+212
View File
@@ -0,0 +1,212 @@
// The parsed-file model of UTILITIES ▸ Import: what a GeoJSON, GPX, KML or
// KMZ file draws, as plain records, engine-neutral and without a Cesium
// DataSource. src/data/geoFileImport.js reads a file into it (GeoJSON and
// GPX here, KML and KMZ in src/data/geoImportKml.js), and
// src/data/geoImportDisplay.js draws it on any globe engine.
//
// Positions are [longitude, latitude, height] in degrees and metres above
// the ellipsoid; colours are rgba in 0..1. Every record has a short
// plain-text `name` (or undefined). The import keeps no descriptions,
// properties or times: a file's HTML never reaches the page, and a dated
// file never moves the app's clock (it draws as it is, for all time).
//
// @typedef {object} ImportedRecords
// @property {ImportedPoint[]} points
// @property {ImportedLine[]} lines
// @property {ImportedPolygon[]} polygons
// @property {ImportedOverlay[]} overlays
// @property {Object<string, number>} undrawn What the file has that no
// engine draws, by kind (`wall`, `extruded`, `latLonQuad`), counted.
//
// @typedef {object} ImportedPoint
// @property {number[]} position
// @property {'ground'|'relative'|'absolute'} altitude On the ground (the
// height is ignored), that high above it, or that high above the
// ellipsoid.
// @property {ImportedIcon} icon
//
// @typedef {object} ImportedIcon How Cesium drew the point's billboard:
// @property {{ pin: number[], pinSize: number } | { url: string }} image
// Cesium's map pin of that colour, drawn at pinSize px, or an image of the
// file's own (a data: or blob: URL; nothing remote).
// @property {number} [widthPx] Drawn width before `scale` (the image's own
// when omitted); `heightPx` likewise.
// @property {number} [heightPx]
// @property {number} [scale] 1 when omitted.
// @property {number[]} [color] Multiplies the image (white when omitted).
// @property {'center'|'bottom'} [origin] The point is the box's centre
// (the default) or the middle of its bottom edge.
// @property {number[]} [offsetPx] [x, y], +y down: the box moved by this.
// @property {number[]} [subRegion] [x, y, width, height] of the image in
// pixels, y up from its bottom (Cesium's imageSubRegion).
// @property {number[]} [scaleByDistance] [nearM, nearScale, farM, farScale].
// @property {number} [rotation] Radians counter-clockwise.
//
// @typedef {object} ImportedLine
// @property {number[][]} positions At least two.
// @property {boolean} ground Draped on the ground (heights ignored), else a
// line through the positions' heights.
// @property {number[]} color
// @property {number} widthPx
//
// @typedef {object} ImportedPolygon
// @property {number[][][]} rings [outer, ...holes], each closed or not.
// @property {boolean} raised Drawn at its positions' heights on Cesium
// (KML absolute and relative altitudes); else draped on the ground.
// @property {number[]|null} fill
// @property {{ color: number[], widthPx: number }|null} outline Cesium
// draws it only on a raised polygon (an outline on terrain is not drawn).
//
// @typedef {object} ImportedOverlay A KML ground overlay.
// @property {string|null} url Its image, or null for a plain colour.
// @property {{ west: number, south: number, east: number, north: number }} rectangle
// @property {number[]} color Multiplies the image; its alpha fades it.
// @property {number} rotation Degrees counter-clockwise (KML LatLonBox).
/** Longest plain-text name kept from a file's features. */
const MAX_IMPORT_NAME_LENGTH = 120;
/** 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;
}
/** An empty set of records. */
export const emptyRecords = () => ({
points: [],
lines: [],
polygons: [],
overlays: [],
undrawn: {},
});
/** How many map objects the records hold. */
export const recordCount = (records) =>
records.points.length +
records.lines.length +
records.polygons.length +
records.overlays.length;
/** `#rrggbb` as rgba in 0..1, with Cesium.Color.fromCssColorString's bytes. */
export function rgbaOfHex(css, alpha = 1) {
const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(
String(css).trim(),
);
if (!match) throw new TypeError(`Unsupported colour: ${css}`);
return [
parseInt(match[1], 16) / 255,
parseInt(match[2], 16) / 255,
parseInt(match[3], 16) / 255,
alpha,
];
}
// The look Cesium's GeoJsonDataSource gave an import in the row's colour.
const GEOJSON_STROKE_PX = 3;
const GEOJSON_FILL_ALPHA = 0.25;
const GEOJSON_PIN_PX = 48;
/**
* The records of a checked GeoJSON object (src/data/geoFileImport.js has
* already cut it to names and ground positions), drawn as Cesium drew it
* in the row's colour `color` (`#rrggbb`): points as a 48 px map pin of
* that colour standing on the ground, lines as 3 px ground lines, polygons
* as a fill at 0.25 (the 3 px outline Cesium leaves out on terrain).
* Multipart geometries and geometry collections give one record per part.
* @param {object} geojson
* @param {string} color
* @returns {ImportedRecords}
*/
export function geoJsonRecords(geojson, color) {
const stroke = rgbaOfHex(color);
const fill = [...stroke.slice(0, 3), GEOJSON_FILL_ALPHA];
const records = emptyRecords();
const position = ([lon, lat, height = 0]) => [lon, lat, height];
const stack = [[geojson, undefined]];
while (stack.length) {
const [node, name] = stack.pop();
if (!node) continue;
if (node.type === 'FeatureCollection') {
for (let i = node.features.length - 1; i >= 0; i--)
stack.push([node.features[i], undefined]);
continue;
}
if (node.type === 'Feature') {
stack.push([node.geometry, node.properties?.name]);
continue;
}
if (node.type === 'GeometryCollection') {
for (let i = node.geometries.length - 1; i >= 0; i--)
stack.push([node.geometries[i], name]);
continue;
}
const parts = node.type.startsWith('Multi')
? node.coordinates
: [node.coordinates];
const kind = node.type.replace(/^Multi/, '');
for (const part of parts) {
if (kind === 'Point')
records.points.push({
name,
position: position(part),
altitude: 'ground',
icon: {
image: { pin: stroke, pinSize: GEOJSON_PIN_PX },
origin: 'bottom',
},
});
else if (kind === 'LineString')
records.lines.push({
name,
positions: part.map(position),
ground: true,
color: stroke,
widthPx: GEOJSON_STROKE_PX,
});
else if (kind === 'Polygon' && part[0]?.length)
records.polygons.push({
name,
rings: part.map((ring) => ring.map(position)),
raised: false,
fill,
outline: { color: stroke, widthPx: GEOJSON_STROKE_PX },
});
}
}
return records;
}
/**
* Every position the records draw, as [lon, lat, height], for framing:
* what lies on the ground at height 0, and each overlay's corners.
* @param {ImportedRecords} records
* @returns {number[][]}
*/
export function recordFootprint(records) {
const out = [];
const add = ([lon, lat, height = 0], grounded) => {
if (Number.isFinite(lon) && Number.isFinite(lat))
out.push([lon, lat, grounded || !Number.isFinite(height) ? 0 : height]);
};
for (const point of records.points)
add(point.position, point.altitude === 'ground');
for (const line of records.lines)
for (const position of line.positions) add(position, line.ground);
for (const polygon of records.polygons)
for (const ring of polygon.rings)
for (const position of ring) add(position, !polygon.raised);
for (const { rectangle: r } of records.overlays) {
const east = r.east < r.west ? r.east + 360 : r.east;
for (const lon of [r.west, east])
for (const lat of [r.south, r.north])
add([lon > 180 ? lon - 360 : lon, lat, 0], true);
}
return out;
}
+1 -1
View File
@@ -24,7 +24,7 @@
import {
worldToWindowCoordinates,
horizonOccluder,
} from './render/cesiumProjection.js';
} from '../globe/cesium/sceneProjection.js';
import * as geo from '../geo/index.js';
// A window position through the engine projector the scene carries (the
+1 -1
View File
@@ -1,6 +1,6 @@
# Sky background
Catalogue stars over a soft Milky Way, drawn by `src/sky/starrySky.js` in
Catalogue stars over a soft Milky Way, drawn by `src/globe/cesium/starrySky.js` in
place of Cesium's blocky Tycho-2 sky box.
| File | Content | Size |
+60 -120
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium';
import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js';
import { NO_IMAGERY_HOST } from '../maps/imageryHost.js';
/**
* @file Open-data raster overlays: OpenSeaMap sea marks and OpenSnowMap ski
@@ -16,10 +15,13 @@ import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js';
* - OpenInfraMap serves MapLibre vector tiles, not raster XYZ tiles. That
* needs a vector-tile rendering pipeline, not an imagery-layer drop-in.
*
* The overlays drape on whatever surface the active map stack offers (see
* `maps/imageryHost.js`): the globe on the OSM and Bing stacks, the Google
* Photorealistic tileset's own `imageryLayers` on Google 3D. Only a stack with
* neither leaves them hidden, and the row says so.
* The overlays drape through the globe engine on whatever surface the
* active map stack offers: `engine.imagery` (on Cesium the globe on the OSM
* and Bing stacks, the Google Photorealistic tileset's own imagery on Google
* 3D; the engine follows stack switches), or Navara's own raster overlay
* (`engine.maps.addOverlay`, per-tile levels on its terrain) where the
* engine has one. Only a surface with neither leaves them hidden, and the
* row says so.
* @module data/osmOverlays
*/
@@ -54,101 +56,66 @@ export function osmOverlayHiddenReason(enabled, hostKind) {
return enabled && hostKind === 'none' ? NO_IMAGERY_HOST : null;
}
// Over the heat rasters, under event imagery and the weather.
const OVERLAY_ORDER = 10;
const HOST_KIND = Object.freeze({ globe: 'globe', tiles: 'tileset' });
/**
* Create the layer for one overlay descriptor. Each application catalog gets
* its own instances; the Cesium factories are injectable so tests run without
* WebGL.
* its own instances.
*/
export function createOsmOverlayLayer(
overlay,
{
host = resolveImageryHost,
createProvider = (descriptor) =>
new Cesium.UrlTemplateImageryProvider({
url: descriptor.urlTemplate,
credit: descriptor.credit,
maximumLevel: descriptor.maximumLevel,
}),
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
} = {},
) {
export function createOsmOverlayLayer(overlay) {
if (!OSM_OVERLAYS.includes(overlay))
throw new TypeError(`Unknown open map overlay: ${overlay?.id}`);
let viewer = null;
// A globe engine without a Cesium viewer (Navara) takes the overlay
// through engine.maps.addOverlay; its removal while shown.
let engine = null;
let removeEngineOverlay = null;
let mapStack = null;
let unsubscribeMap = null;
let enabled = false;
let hostInfo = { collection: null, kind: 'none' };
/** The imagery layer this instance owns while enabled, and where it sits. */
/** The drape this instance owns while enabled: an imagery layer or a removal. */
let owned = null;
const usable = (collection) => collection && !collection.isDestroyed?.();
/** Where the drape lies now: 'globe', 'tileset' or 'none'. */
function hostKind() {
if (!owned) return 'none';
if (owned.remove) return 'globe';
return HOST_KIND[engine?.imagery?.surface?.()] ?? 'none';
}
function detach(destroy) {
function release() {
if (!owned) return;
const { collection, layer } = owned;
if (usable(collection) && collection.contains(layer))
collection.remove(layer, destroy);
else if (destroy && !layer.isDestroyed?.()) layer.destroy?.();
owned.collection = null;
if (destroy) owned = null;
const { layer, remove } = owned;
owned = null;
if (remove) remove();
else layer.destroy();
}
function resolveHost() {
return (
host({ viewer, tileset: mapStack?.getImageryHostTileset?.() }) || {
collection: null,
kind: 'none',
}
);
}
/** Create, move or drop the owned imagery layer to match the state. */
/** Create or drop the owned drape to match the state. */
function sync() {
if (!viewer) {
syncEngine();
if (!engine || !enabled) {
release();
return;
}
hostInfo = enabled ? resolveHost() : { collection: null, kind: 'none' };
if (!enabled) {
detach(true);
} else {
owned ||= {
layer: createImageryLayer(createProvider(overlay)),
collection: null,
};
if (owned.collection !== hostInfo.collection) {
detach(false);
if (usable(hostInfo.collection)) {
// Appended, so it draws above the map stack's base imagery.
hostInfo.collection.add(owned.layer);
owned.collection = hostInfo.collection;
}
}
if (owned) return;
const remove = engine.maps?.addOverlay?.({
url: overlay.urlTemplate,
maxZoom: overlay.maximumLevel,
credit: overlay.credit,
});
if (typeof remove === 'function') {
owned = { remove };
return;
}
viewer.scene?.requestRender?.();
}
function syncEngine() {
if (!engine) return;
if (enabled && !removeEngineOverlay)
removeEngineOverlay =
engine.maps?.addOverlay?.({
if (!engine.imagery) return;
owned = {
layer: engine.imagery.createLayer({
id: `osm-overlay-${overlay.id}`,
source: {
kind: 'tiles',
url: overlay.urlTemplate,
maxZoom: overlay.maximumLevel,
maxLevel: overlay.maximumLevel,
credit: overlay.credit,
}) ?? null;
else if (!enabled && removeEngineOverlay) {
removeEngineOverlay();
removeEngineOverlay = null;
}
hostInfo = removeEngineOverlay
? { collection: null, kind: 'globe' }
: { collection: null, kind: 'none' };
},
order: OVERLAY_ORDER,
}),
};
}
return {
@@ -157,33 +124,19 @@ export function createOsmOverlayLayer(
icon: overlay.icon,
source: overlay.source,
updateInterval: 0,
init(nextViewer, { engine: nextEngine = null } = {}) {
this.attachRenderer(nextViewer, { engine: nextEngine });
init(viewer, options) {
this.attachRenderer(viewer, options);
},
/** Drape on a globe: the Cesium viewer's imagery, or the engine's. */
attachRenderer(nextViewer, { engine: nextEngine = null } = {}) {
viewer = nextViewer ?? null;
engine = viewer ? null : nextEngine;
/** Drape on a globe engine's imagery. */
attachRenderer(_viewer, { engine: nextEngine = null } = {}) {
release();
engine = nextEngine;
sync();
},
/** Leave the globe; the enabled state stays for the next one. */
detachRenderer() {
detach(true);
removeEngineOverlay?.();
removeEngineOverlay = null;
viewer = null;
release();
engine = null;
hostInfo = { collection: null, kind: 'none' };
},
/** Follow map-stack switches so the drape moves with the surface. */
attachMapStackController(controller) {
unsubscribeMap?.();
unsubscribeMap = null;
mapStack = controller || null;
const off = controller?.subscribe?.(() => {
if (enabled) sync();
});
unsubscribeMap = typeof off === 'function' ? off : null;
},
enable() {
enabled = true;
@@ -197,32 +150,19 @@ export function createOsmOverlayLayer(
return true;
},
getStats() {
// Without a controller subscription the stats poll doubles as the
// map-stack watch.
if (enabled && viewer && !unsubscribeMap) {
const next = resolveHost();
if (next.collection !== hostInfo.collection) sync();
}
return {
count: enabled ? 1 : 0,
error: osmOverlayHiddenReason(enabled, hostInfo.kind),
error: osmOverlayHiddenReason(enabled, hostKind()),
};
},
/** Owned layer count and host kind, for tests and live gates. */
/** Owned drape count and host kind, for tests and live gates. */
diagnostics() {
return { host: hostInfo.kind, ownedCount: owned ? 1 : 0 };
return { host: hostKind(), ownedCount: owned ? 1 : 0 };
},
destroy() {
enabled = false;
detach(true);
removeEngineOverlay?.();
removeEngineOverlay = null;
release();
engine = null;
unsubscribeMap?.();
unsubscribeMap = null;
mapStack = null;
viewer = null;
hostInfo = { collection: null, kind: 'none' };
},
};
}
+46 -94
View File
@@ -7,67 +7,25 @@ import {
osmOverlayHiddenReason,
} from './osmOverlays.js';
import { NO_IMAGERY_HOST } from '../maps/imageryHost.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
function fakeCollection() {
const layers = [];
const removed = [];
return {
layers,
removed,
add(layer) {
layers.push(layer);
},
contains(layer) {
return layers.includes(layer);
},
remove(layer, destroy) {
const index = layers.indexOf(layer);
if (index < 0) return false;
layers.splice(index, 1);
removed.push({ layer, destroy });
if (destroy) layer.destroyed = true;
return true;
},
};
}
function fakeMapStack() {
const subscribers = new Set();
return {
tileset: null,
getImageryHostTileset() {
return this.tileset;
},
subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
},
emit() {
for (const fn of subscribers) fn();
},
subscribers,
};
}
/** A layer on the fake engine, whose imagery drapes where `surface()` says. */
function setup(overlay = OSM_OVERLAYS[0]) {
const globe = fakeCollection();
const viewer = {
imageryLayers: globe,
scene: { globe: { show: true }, requestRender() {} },
let surface = 'globe';
const fake = createFakeEngine({ kind: 'cesium' });
const engine = {
...fake,
imagery: { ...fake.imagery, surface: () => surface },
};
const layer = createOsmOverlayLayer(overlay);
layer.init(null, { engine });
return {
layer,
engine,
layers: () =>
engine.imagery.list().filter(({ id }) => id.startsWith('osm-overlay-')),
setSurface: (next) => (surface = next),
};
const created = [];
const layer = createOsmOverlayLayer(overlay, {
createProvider: (descriptor) => ({ descriptor }),
createImageryLayer: (provider) => {
const imagery = { provider, destroyed: false };
created.push(imagery);
return imagery;
},
});
const mapStack = fakeMapStack();
layer.init(viewer);
layer.attachMapStackController(mapStack);
return { layer, viewer, globe, created, mapStack };
}
test('OSM_OVERLAYS: the two shipped layers, each with a tile template and credit', () => {
@@ -104,66 +62,60 @@ test('osmOverlayHiddenReason: only when enabled with no imagery host', () => {
});
test('a layer owns no imagery until enabled and destroys it on disable', () => {
const { layer, globe, created } = setup(OSM_OVERLAYS[1]);
assert.equal(created.length, 0);
const { layer, engine, layers } = setup(OSM_OVERLAYS[1]);
assert.equal(layers().length, 0);
layer.enable();
assert.equal(globe.layers.length, 1);
assert.equal(globe.layers[0].provider.descriptor.id, 'ski-pistes');
assert.equal(layers().length, 1);
const [{ id }] = layers();
assert.equal(id, 'osm-overlay-ski-pistes');
const { source, options } = engine.fake.imageryLayer(id);
assert.equal(source.url, OSM_OVERLAYS[1].urlTemplate);
assert.equal(source.maxLevel, 18);
assert.equal(source.credit, OSM_OVERLAYS[1].credit);
assert.equal(options.order, 10);
assert.deepEqual(layer.getStats(), { count: 1, error: null });
layer.disable();
assert.equal(globe.layers.length, 0);
assert.equal(created[0].destroyed, true);
assert.equal(layers().length, 0);
assert.deepEqual(layer.diagnostics(), { host: 'none', ownedCount: 0 });
assert.deepEqual(layer.getStats(), { count: 0, error: null });
layer.enable();
assert.equal(created.length, 2, 're-enabling creates a fresh imagery layer');
assert.equal(layers().length, 1, 're-enabling creates a fresh layer');
});
test('the overlay follows a map-stack switch onto the Google 3D tileset and back', () => {
const { layer, viewer, globe, mapStack } = setup();
test('the overlay reports the surface the engine drapes it on', () => {
const { layer, setSurface } = setup();
layer.enable();
const [imagery] = globe.layers;
const tileset = { imageryLayers: fakeCollection() };
viewer.scene.globe.show = false;
mapStack.tileset = tileset;
mapStack.emit();
assert.equal(globe.layers.length, 0);
assert.deepEqual(tileset.imageryLayers.layers, [imagery]);
assert.equal(globe.removed[0].destroy, false);
assert.equal(layer.diagnostics().host, 'globe');
setSurface('tiles');
assert.equal(layer.diagnostics().host, 'tileset');
assert.equal(layer.getStats().error, null);
viewer.scene.globe.show = true;
mapStack.tileset = null;
mapStack.emit();
assert.deepEqual(globe.layers, [imagery]);
assert.equal(tileset.imageryLayers.layers.length, 0);
});
test('with no surface to drape on the row explains itself, and recovers when one appears', () => {
const { layer, viewer, globe, mapStack } = setup();
viewer.scene.globe.show = false;
const { layer, setSurface } = setup();
setSurface('none');
layer.enable();
assert.equal(globe.layers.length, 0);
assert.equal(layer.getStats().error, NO_IMAGERY_HOST);
viewer.scene.globe.show = true;
mapStack.emit();
assert.equal(globe.layers.length, 1);
setSurface('globe');
assert.equal(layer.getStats().error, null);
});
test('destroy removes owned imagery and unsubscribes from the map stack', () => {
const { layer, globe, created, mapStack } = setup();
test('destroy and a renderer detach remove the owned imagery', () => {
const { layer, layers, engine } = setup();
layer.enable();
layer.detachRenderer();
assert.equal(layers().length, 0);
layer.attachRenderer(null, { engine });
assert.equal(
layers().length,
1,
'the enabled state stays for the next globe',
);
layer.destroy();
assert.equal(globe.layers.length, 0);
assert.equal(created[0].destroyed, true);
assert.equal(mapStack.subscribers.size, 0);
assert.equal(layers().length, 0);
});
test('on Navara an overlay drapes through engine.maps and follows the globe', () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { trackingEventTypes } from './render/cesiumConstants.js';
import { trackingEventTypes } from '../globe/cesium/screenEvents.js';
import { isPointerFree } from './inputOwnership.js';
export const MAX_TRACKING_CLICK_TRAVEL_PX = 6;
+1 -1
View File
@@ -1 +1 @@
export { createTrail } from './render/cesiumTrailRenderer.js';
export { createTrail } from '../globe/cesium/trail.js';
@@ -39,7 +39,7 @@
* boost. Parking our fix on `enabled` would let that restore resurrect
* the broken shader mid-session; `renderable` is orthogonal to it.
*
* @module app/atmosphereCompat
* @module globe/cesium/atmosphereCompat
*/
/** Minimal vertex shader reproducing the exact out-param-to-varying bind. */
File renamed without changes.
File renamed without changes.
+7 -1
View File
@@ -36,10 +36,13 @@ import { createCesiumSurface } from './surface.js';
* lineFactories?: object,
* groundFactories?: object,
* imageryFactories?: object,
* createStage?: Function,
* createSonar?: Function,
* }} [options] `requestRender` defaults to the render governor; `appClock`
* to the page's app clock; the `create*`/`load*` options and the mover and
* line, ground and imagery factories (movers.js, lines.js, ground.js,
* imagery.js) are test seams.
* imagery.js) are test seams, `createStage` and `createSonar` too
* (render.js).
*
* `render.setContinuous` takes a hold on the app's singleton renderGovernor
* (src/renderGovernor.js), which governs the one viewer it was installed on;
@@ -107,6 +110,9 @@ export function createCesiumEngine(viewer, options = {}) {
kind: 'cesium',
// What only Cesium has, for code still written against the viewer.
extensions: Object.freeze({ cesium: Object.freeze({ viewer }) }),
// Cesium only, not contract: what the contact sonar holds (sonar.js),
// live; the app reads whether its command adapter runs here.
contactSonarDiagnostics: render.sonarDiagnostics,
host: {
container: viewer.container,
canvas,
+18 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { assertGlobeEngine } from '../contract.js';
import { assertGlobeEngine, engineCapabilities } from '../contract.js';
import {
_resetRenderGovernorForTest,
getRenderGovernorDiagnostics,
@@ -55,6 +55,23 @@ test('the engine satisfies the contract shape and changes nothing at creation',
assert.deepEqual(engine.host.size(), { width: 640, height: 600 });
});
test('the engine draws the visual styles, bloom and contact sonar, not the cloud cover', () => {
const viewer = createFakeViewer();
const engine = createCesiumEngine(viewer);
assert.deepEqual(engineCapabilities(engine), {
visualStyles: true,
bloom: true,
cloudCover: false,
contactSonar: true,
movers: true,
lines: true,
ground: true,
imagery: true,
});
assert.equal(engine.contactSonarDiagnostics().contacts, 0);
engine.destroy();
});
test('the render option reaches render.request', () => {
const viewer = createFakeViewer();
const reasons = [];
+22 -1
View File
@@ -48,6 +48,11 @@ export function createCesiumInput(
let handler = null;
let destroyed = false;
const groups = new Map();
// Camera-input suspensions held, and the enableInputs the last resume
// puts back: the controller's own from before the first, or what
// setCameraInputs asked for since.
let suspended = 0;
let resumeTo = true;
const delivery = createPickDelivery({
pick: (position) => picker?.pick(position),
drill: (position, limit) => picker?.drillPick(position, limit) ?? [],
@@ -84,7 +89,23 @@ export function createCesiumInput(
};
},
setCameraInputs(enabled) {
scene.screenSpaceCameraController.enableInputs = Boolean(enabled);
if (suspended) resumeTo = Boolean(enabled);
else scene.screenSpaceCameraController.enableInputs = Boolean(enabled);
},
// The controller's switch: whatever else turned it off counts too.
cameraInputsEnabled: () =>
scene.screenSpaceCameraController?.enableInputs !== false,
suspendCameraInputs() {
const controller = scene.screenSpaceCameraController;
if (suspended++ === 0) resumeTo = controller.enableInputs;
controller.enableInputs = false;
let resumed = false;
return () => {
if (resumed) return;
resumed = true;
if (--suspended === 0)
scene.screenSpaceCameraController.enableInputs = resumeTo;
};
},
};
return {
+26
View File
@@ -105,6 +105,32 @@ test('setCameraInputs toggles the camera controller', () => {
assert.equal(viewer.scene.screenSpaceCameraController.enableInputs, true);
});
test('suspendCameraInputs counts and the last resume restores enableInputs', () => {
const { viewer, input } = setup();
const controller = viewer.scene.screenSpaceCameraController;
assert.equal(input.cameraInputsEnabled(), true);
const outer = input.suspendCameraInputs();
const inner = input.suspendCameraInputs();
assert.equal(controller.enableInputs, false);
assert.equal(input.cameraInputsEnabled(), false);
inner();
inner();
assert.equal(controller.enableInputs, false, 'the outer one still holds');
outer();
assert.equal(controller.enableInputs, true);
assert.equal(input.cameraInputsEnabled(), true);
// Off before the first suspension: off again after the last.
controller.enableInputs = false;
input.suspendCameraInputs()();
assert.equal(controller.enableInputs, false);
// A choice made under a suspension applies at its resume.
const resume = input.suspendCameraInputs();
input.setCameraInputs(true);
assert.equal(controller.enableInputs, false);
resume();
assert.equal(controller.enableInputs, true);
});
test('destroy destroys the handler', () => {
const { handlers, input, destroy } = setup();
input.on('click', () => {});
@@ -3,7 +3,7 @@ import {
SENTINEL2_CREDIT,
SENTINEL2_MAX_ZOOM,
SENTINEL2_URL,
} from './sentinel2.js';
} from '../../maps/sentinel2.js';
// Attribution and service rights are documented in DATA_SOURCES.md.
export const ESRI_ATTRIBUTION_HTML =
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { createCesiumCredits } from '../globe/cesium/credits.js';
import { createMapCredits } from './credits.js';
import { createCesiumCredits } from './credits.js';
import { createMapCredits } from '../../maps/credits.js';
/**
* The scene changes a map source makes on a Cesium viewer: the base imagery
@@ -10,7 +10,7 @@ import { createMapCredits } from './credits.js';
* @param {Cesium.Viewer} viewer
* @param {{
* createImageryLayer?: (provider: object) => object,
* credits?: import('../globe/contract.js').GlobeEngine['credits'],
* credits?: import('../contract.js').GlobeEngine['credits'],
* }} [options] `credits`: the globe engine's credits (default: the viewer's
* credit display through the Cesium engine's credits).
*/
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMapScene } from './sceneApply.js';
import { createMapScene } from './mapScene.js';
function fixture() {
const layers = [];
@@ -91,7 +91,7 @@ test('removeBaseImagery, tilesets, globe and credits', () => {
});
test('the map scene shows its credit through a given credits capability', async () => {
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
const { createFakeEngine } = await import('../testing/fakeEngine.js');
const engine = createFakeEngine();
const { viewer, credits } = fixture();
const scene = createMapScene(viewer, {
+3 -6
View File
@@ -5,12 +5,9 @@
import * as Cesium from 'cesium';
import { loadPhotorealisticTileset } from '../../maps/google3d.js';
import { createMapScene } from '../../maps/sceneApply.js';
import {
ESRI_ATTRIBUTION_HTML,
imageryFactoryFor,
} from '../../maps/imagery.js';
import { applyTerrain, terrainSourceFor } from '../../maps/terrain.js';
import { createMapScene } from './mapScene.js';
import { ESRI_ATTRIBUTION_HTML, imageryFactoryFor } from './mapImagery.js';
import { applyTerrain, terrainSourceFor } from './terrain.js';
const IMAGERY_KINDS = new Set(['osm', 'esri-imagery', 'sentinel2', 'ion']);
+1 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { MAP_STACKS } from '../../maps/catalog.js';
import { createDefaultMapSources } from '../../maps/defaultSources.js';
import { ESRI_ATTRIBUTION_HTML } from '../../maps/imagery.js';
import { ESRI_ATTRIBUTION_HTML } from './mapImagery.js';
import { createCesiumMaps } from './maps.js';
import { createFakeViewer } from './testing/fakeViewer.mjs';
+244
View File
@@ -0,0 +1,244 @@
// The app's full-frame effects on Cesium (GlobeRender's visualStyles,
// bloom and setSharpen), through the scene's post-process stages: one stage
// per visual style, each the style's own shader (src/styles), then the
// sharpening; the bloom is the scene's own stage, borrowed. Cesium draws its
// stages in the order they were added, so the six join the scene together,
// styles first, at the first style or sharpen call (creating the engine
// changes nothing), and each is enabled only while it draws. Anime fades the
// sky box's Milky Way with its intensity (starrySky.js). destroy() removes
// the stages and gives the bloom stage back as it was borrowed.
import { PostProcessStage } from 'cesium';
import {
BLOOM_INITIAL,
SHARPEN_INITIAL,
STYLE_INITIAL,
mergeBloom,
mergeSharpen,
mergeStyle,
} from '../contract.js';
import { retroShader } from '../../styles/retro.js';
import { nightVisionShader } from '../../styles/surveillance.js';
import { thermalShader } from '../../styles/thermal.js';
import { animeShader } from '../../styles/anime.js';
import { noirShader } from '../../styles/noir.js';
// The app's styles by their names, in their stage order
// (src/ui/visualPresets.js STYLES).
const STYLE_SHADERS = Object.freeze({
retro: retroShader,
surveillance: nightVisionShader,
thermal: thermalShader,
anime: animeShader,
noir: noirShader,
});
/** Intensity under which a style draws nothing (its stage is disabled). */
const STYLE_VISIBLE_INTENSITY = 0.001;
/** Strength at or under which the bloom draws nothing. */
const BLOOM_DEAD_ZONE = 0.06;
// The bloom stage's uniforms the engine sets, given back on destroy.
const BLOOM_UNIFORMS = [
'glowOnly',
'contrast',
'brightness',
'delta',
'sigma',
'stepSize',
];
/**
* GLSL fragment shader implementing an unsharp-mask sharpening filter.
* Samples a 3x3 neighborhood, computes box blur, then adds the
* difference (center - blur) scaled by `amount` for edge enhancement.
*/
export const SHARPEN_SHADER = /* glsl */ `
uniform sampler2D colorTexture;
uniform vec2 colorTextureDimensions;
uniform float amount;
in vec2 v_textureCoordinates;
void main() {
vec2 uv = v_textureCoordinates;
vec2 texel = 1.0 / colorTextureDimensions;
vec4 center = texture(colorTexture, uv);
vec4 blur = (
texture(colorTexture, uv + vec2(-texel.x, -texel.y)) +
texture(colorTexture, uv + vec2( 0.0, -texel.y)) +
texture(colorTexture, uv + vec2( texel.x, -texel.y)) +
texture(colorTexture, uv + vec2(-texel.x, 0.0)) +
center +
texture(colorTexture, uv + vec2( texel.x, 0.0)) +
texture(colorTexture, uv + vec2(-texel.x, texel.y)) +
texture(colorTexture, uv + vec2( 0.0, texel.y)) +
texture(colorTexture, uv + vec2( texel.x, texel.y))
) / 9.0;
vec4 sharpened = center + (center - blur) * amount;
out_FragColor = vec4(clamp(sharpened.rgb, 0.0, 1.0), center.a);
}
`;
/** A style's uniforms before it is first set: `time` if it animates, then its parameters' defaults. */
function styleDefaults(shader) {
const uniforms = {};
if (shader.fragmentShader.includes('uniform float time')) uniforms.time = 0;
for (const [name, meta] of Object.entries(shader.uniforms || {}))
uniforms[name] = meta.default;
return uniforms;
}
/**
* Cesium's bloom stage uniforms for a strength (0..1): above the dead zone
* the strength eases (smoothstep) into a lower contrast, a brighter offset
* and a wider blur, so more of the frame glows, further out.
*/
export function bloomUniforms(strength) {
const raw =
strength <= BLOOM_DEAD_ZONE
? 0
: (strength - BLOOM_DEAD_ZONE) / (1 - BLOOM_DEAD_ZONE);
const eased = raw * raw * (3 - 2 * raw);
return {
contrast: 255 - eased * 168,
brightness: -0.5 + eased * 0.36,
sigma: 0.28 + eased * 6.3,
delta: 0.2 + eased * 2.25,
stepSize: 1 + eased * 1.25,
};
}
/**
* @param {Cesium.Viewer} viewer
* @param {{ requestRender: (reason: string) => void,
* createStage?: (options: object) => object }} options `createStage` is
* a test seam (Cesium's PostProcessStage).
* @returns {{ render: object, destroy: () => void }} `render`: the
* GlobeRender members (visualStyles, setStyle, style, setBloom, bloom,
* setSharpen, sharpen).
*/
export function createCesiumPostStages(
viewer,
{ requestRender, createStage = (options) => new PostProcessStage(options) },
) {
const { scene } = viewer;
const styles = new Map(
Object.entries(STYLE_SHADERS).map(([name, shader]) => {
const uniforms = styleDefaults(shader);
return [
name,
{
shader,
names: Object.keys(uniforms),
state: { ...STYLE_INITIAL, uniforms },
stage: null,
},
];
}),
);
let sharpen = { ...SHARPEN_INITIAL };
let sharpenStage = null;
let bloom = { ...BLOOM_INITIAL };
let borrowed = null;
let destroyed = false;
// Every stage at once, in draw order: the styles, then the sharpening.
const ensureStages = () => {
if (sharpenStage || destroyed) return;
for (const [name, style] of styles) {
style.stage = createStage({
name: `godsEyeView_${name}`,
fragmentShader: style.shader.fragmentShader,
uniforms: { intensity: 0, ...style.state.uniforms },
});
style.stage.enabled = false;
scene.postProcessStages.add(style.stage);
}
sharpenStage = createStage({
name: 'godsEyeView_sharpen',
fragmentShader: SHARPEN_SHADER,
uniforms: { amount: sharpen.amount },
});
sharpenStage.enabled = false;
scene.postProcessStages.add(sharpenStage);
};
const knownStyle = (name) => {
const style = styles.get(name);
if (!style) throw new TypeError(`Unknown visual style: ${name}`);
return style;
};
const showStyle = (name, { stage, state }) => {
Object.assign(stage.uniforms, state.uniforms);
stage.uniforms.intensity = state.intensity;
stage.enabled = state.intensity > STYLE_VISIBLE_INTENSITY;
// Anime's bands turn the faint Milky Way into blotches, and depth
// cannot tell the sky from far geometry, so the glow fades with it.
if (name === 'anime' && scene.skyBox?.milkyWayVisibility !== undefined)
scene.skyBox.milkyWayVisibility = 1 - state.intensity;
};
// The scene's bloom stage, set up for the app's bloom the first time.
const bloomStage = () => {
const stage = scene.postProcessStages.bloom;
if (!borrowed) {
borrowed = {
enabled: stage.enabled,
uniforms: Object.fromEntries(
BLOOM_UNIFORMS.map((name) => [name, stage.uniforms[name]]),
),
};
stage.uniforms.glowOnly = false;
}
return stage;
};
const render = {
visualStyles: Object.freeze([...styles.keys()]),
setStyle(name, options) {
const style = knownStyle(name);
style.state = mergeStyle(style.state, options, style.names);
if (destroyed) return;
ensureStages();
showStyle(name, style);
requestRender('style-stage');
},
style(name) {
const { intensity, uniforms } = knownStyle(name).state;
return { intensity, uniforms: { ...uniforms } };
},
setBloom(options) {
bloom = mergeBloom(bloom, options);
if (destroyed) return;
const stage = bloomStage();
Object.assign(stage.uniforms, bloomUniforms(bloom.strength));
stage.enabled = bloom.enabled && bloom.strength > BLOOM_DEAD_ZONE;
requestRender('bloom');
},
bloom: () => ({ ...bloom }),
setSharpen(options) {
sharpen = mergeSharpen(sharpen, options);
if (destroyed) return;
ensureStages();
sharpenStage.uniforms.amount = sharpen.amount;
sharpenStage.enabled = sharpen.enabled;
requestRender('sharpen');
},
sharpen: () => ({ ...sharpen }),
};
return {
render,
destroy() {
if (destroyed) return;
destroyed = true;
const collection = scene.postProcessStages;
if (!collection || collection.isDestroyed?.()) return;
if (sharpenStage) {
for (const { stage } of styles.values()) collection.remove(stage);
collection.remove(sharpenStage);
}
if (borrowed) {
const stage = collection.bloom;
Object.assign(stage.uniforms, borrowed.uniforms);
stage.enabled = borrowed.enabled;
}
},
};
}
+149
View File
@@ -0,0 +1,149 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { bloomUniforms, createCesiumPostStages } from './postStages.js';
function setup() {
const stages = [];
const requests = [];
const bloom = {
enabled: false,
uniforms: {
glowOnly: true,
contrast: 128,
brightness: -0.3,
delta: 1,
sigma: 3.78,
stepSize: 5,
},
};
const original = structuredClone(bloom);
const scene = {
skyBox: { milkyWayVisibility: 1 },
postProcessStages: {
bloom,
add: (stage) => stages.push(stage),
remove: (stage) => {
const index = stages.indexOf(stage);
if (index >= 0) stages.splice(index, 1);
return index >= 0;
},
},
};
const effects = createCesiumPostStages(
{ scene },
{
requestRender: (reason) => requests.push(reason),
createStage: (options) => ({ ...options, enabled: true }),
},
);
const stage = (name) =>
stages.find((entry) => entry.name === `godsEyeView_${name}`);
return {
effects,
render: effects.render,
scene,
stages,
stage,
requests,
bloom,
original,
};
}
test('creation adds nothing; the first style or sharpen call adds all six, in draw order', () => {
const { render, stages } = setup();
assert.deepEqual(render.visualStyles, [
'retro',
'surveillance',
'thermal',
'anime',
'noir',
]);
render.bloom();
render.style('thermal');
assert.equal(stages.length, 0);
render.setSharpen({ amount: 1.5 });
assert.deepEqual(
stages.map(({ name }) => name),
[
'godsEyeView_retro',
'godsEyeView_surveillance',
'godsEyeView_thermal',
'godsEyeView_anime',
'godsEyeView_noir',
'godsEyeView_sharpen',
],
);
assert.ok(stages.every((stage) => !stage.enabled));
render.setStyle('noir', { intensity: 0.5 });
assert.equal(stages.length, 6);
});
test('a style draws above 0.001 with its uniforms; style() is a copy; unknown names throw', () => {
const { render, stage, requests } = setup();
assert.deepEqual(render.style('thermal'), {
intensity: 0,
uniforms: {
time: 0,
sensitivity: 0.75,
bloom: 0.65,
mode: 0,
pixelation: 1.5,
palette: 0,
},
});
render.setStyle('thermal', { intensity: 0.0005 });
assert.equal(stage('thermal').enabled, false);
render.setStyle('thermal', { intensity: 1, uniforms: { palette: 1 } });
assert.equal(stage('thermal').enabled, true);
assert.equal(stage('thermal').uniforms.intensity, 1);
assert.equal(stage('thermal').uniforms.palette, 1);
render.style('thermal').uniforms.palette = 0;
assert.equal(render.style('thermal').uniforms.palette, 1);
assert.ok(requests.includes('style-stage'));
assert.throws(() => render.setStyle('sepia', {}), TypeError);
assert.throws(
() => render.setStyle('noir', { uniforms: { palette: 1 } }),
TypeError,
);
assert.throws(() => render.setStyle('noir', { intensity: 2 }), TypeError);
});
test('Anime fades the Milky Way with its intensity; a sky without one is left alone', () => {
const { render, scene } = setup();
render.setStyle('anime', { intensity: 0.25 });
assert.equal(scene.skyBox.milkyWayVisibility, 0.75);
render.setStyle('thermal', { intensity: 1 });
assert.equal(scene.skyBox.milkyWayVisibility, 0.75);
render.setStyle('anime', { intensity: 0 });
assert.equal(scene.skyBox.milkyWayVisibility, 1);
scene.skyBox = {};
render.setStyle('anime', { intensity: 1 });
assert.equal('milkyWayVisibility' in scene.skyBox, false);
});
test('bloom eases the strength above its dead zone and gives the stage back on destroy', () => {
const { effects, render, bloom, original, stages } = setup();
render.setBloom({ enabled: true, strength: 0.05 });
assert.equal(bloom.enabled, false, 'the dead zone glows nothing');
assert.equal(bloom.uniforms.glowOnly, false);
render.setBloom({ strength: 1 });
assert.equal(bloom.enabled, true);
assert.deepEqual(bloom.uniforms, {
glowOnly: false,
...bloomUniforms(1),
});
assert.equal(bloom.uniforms.contrast, 87);
render.setBloom({ enabled: false });
assert.equal(bloom.enabled, false);
assert.deepEqual(render.bloom(), { enabled: false, strength: 1 });
render.setSharpen({ enabled: true });
effects.destroy();
effects.destroy();
assert.equal(stages.length, 0);
assert.deepEqual(bloom, original);
// Later calls keep the state only.
render.setStyle('noir', { intensity: 1 });
assert.equal(stages.length, 0);
assert.equal(render.style('noir').intensity, 1);
});
+57 -12
View File
@@ -1,6 +1,8 @@
// GlobeRender on the app's render governor, which owns Cesium's
// requestRenderMode and maximumRenderTimeChange; the engine only takes a
// hold under its own owner id.
// hold under its own owner id. The full-frame effects (visual styles,
// bloom, sharpening) are the scene's post stages (postStages.js), the
// contact sonar the scene's native draw commands (sonar.js).
import {
governorRequestRender,
@@ -8,16 +10,22 @@ import {
isRenderGovernorInstalled,
releaseContinuousRender,
} from '../../renderGovernor.js';
import { SHARPEN_INITIAL, mergeSharpen } from '../contract.js';
import { CONTACT_SONAR_INITIAL, mergeContactSonar } from '../contract.js';
import { createCesiumPostStages } from './postStages.js';
import { createCesiumSonar } from './sonar.js';
export const RENDER_OWNER = 'globe-engine';
/**
* @param {Cesium.Viewer} viewer
* @param {{ requestRender?: (reason: string) => void }} [options] Defaults to
* the render governor when it governs this viewer, else the scene itself
* (before the governor is installed, or on another viewer).
* @returns {{ render: import('../contract.js').GlobeRender, destroy: () => void }}
* @param {{ requestRender?: (reason: string) => void,
* createStage?: (options: object) => object,
* createSonar?: typeof createCesiumSonar }} [options] `requestRender`
* defaults to the render governor when it governs this viewer, else the
* scene itself (before the governor is installed, or on another viewer);
* `createStage` (postStages.js) and `createSonar` are test seams.
* @returns {{ render: import('../contract.js').GlobeRender,
* sonarDiagnostics: () => object, destroy: () => void }}
*/
export function createCesiumRender(
viewer,
@@ -26,10 +34,36 @@ export function createCesiumRender(
if (isRenderGovernorInstalled(viewer)) governorRequestRender(reason);
else viewer.scene.requestRender?.();
},
createStage,
createSonar = createCesiumSonar,
} = {},
) {
let held = false;
let sharpen = { ...SHARPEN_INITIAL };
const effects = createCesiumPostStages(viewer, {
requestRender,
...(createStage ? { createStage } : {}),
});
const sonar = createSonar(viewer);
let contactSonar = { ...CONTACT_SONAR_INITIAL };
// Whether Cesium is drawing a frame: a sonar change outside one asks for
// a frame, one inside it (the app's, every frame) draws in it.
let drawing = false;
let removeFrameWatch = null;
const watchFrames = () => {
const { scene } = viewer;
if (removeFrameWatch || !scene.preUpdate?.addEventListener) return;
const removers = [
scene.preUpdate.addEventListener(() => {
drawing = true;
}),
scene.postRender.addEventListener(() => {
drawing = false;
}),
];
removeFrameWatch = () => {
for (const remove of removers) remove();
};
};
const render = {
request(reason = RENDER_OWNER) {
requestRender(reason);
@@ -41,17 +75,28 @@ export function createCesiumRender(
},
// The scene's real mode: another owner's hold keeps it continuous.
isContinuous: () => viewer.scene.requestRenderMode === false,
// State only: in Cesium mode the app's own post stage sharpens
// (src/ui/visualEffects.js), and the scene stays as it was.
setSharpen(options) {
sharpen = mergeSharpen(sharpen, options);
...effects.render,
setContactSonar(options) {
const next = mergeContactSonar(contactSonar, options);
const changed = Object.keys(next).some(
(key) => next[key] !== contactSonar[key],
);
contactSonar = next;
if (!changed) return;
watchFrames();
sonar.set({ ...next });
if (!drawing) requestRender('cyber-sonar');
},
sharpen: () => ({ ...sharpen }),
contactSonar: () => ({ ...contactSonar }),
};
return {
render,
sonarDiagnostics: () => sonar.diagnostics(),
destroy() {
if (held) render.setContinuous(false);
removeFrameWatch?.();
sonar.destroy();
effects.destroy();
},
};
}
File renamed without changes.
File renamed without changes.
+214
View File
@@ -0,0 +1,214 @@
// The Cyber HUD sonar's contact treatment on Cesium (render.setContactSonar,
// the `contactSonar` capability), drawn from the state the app hands the
// engine each frame (src/cyberSonarEngine.js). Native dots, icons and
// glyphs get derived draw commands whose shaders apply the sweep
// (sonarCommands.js); models keep their reversible CPU colour treatment
// (src/cyberSonar.js applyCyberSonarPrimitive), without walking native
// records. Nothing joins the scene before the treatment is first on; turning
// it off gives the models their colours back at once.
import * as Cesium from 'cesium';
import {
applyCyberSonarPrimitive,
cyberSonarSampler,
} from '../../cyberSonar.js';
import {
CYBER_SONAR_CESIUM_VERSION,
createCyberSonarGpu,
} from './sonarCommands.js';
const alive = (value) => value && !value.isDestroyed?.();
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);
}
}
/**
* The sonar on `viewer`'s scene. `set(state)` takes the merged
* ContactSonar (src/globe/contract.js) for the next drawn frame;
* `diagnostics()` is what the treatment holds, live: whether the command
* adapter runs on this Cesium (`supported`), the models it tinted last
* frame (`contacts`) and the native batches it drew (`gpu`: commands,
* visibleCommands, programs, error).
* @param {Cesium.Viewer} viewer
* @param {{ createGpu?: typeof createCyberSonarGpu }} [options] A test seam.
*/
export function createCesiumSonar(
viewer,
{ createGpu = createCyberSonarGpu } = {},
) {
const scene = viewer?.scene;
let state = null;
let treatment = null;
const stats = { contacts: 0, updateMs: 0, frames: 0, gpu: null };
// Read every frame (src/cyberSonarEngine.js): one live view, no copies.
const report = Object.freeze({
get supported() {
return (
stats.gpu?.supported ?? Cesium.VERSION === CYBER_SONAR_CESIUM_VERSION
);
},
get contacts() {
return stats.contacts;
},
get updateMs() {
return stats.updateMs;
},
get frames() {
return stats.frames;
},
get gpu() {
return stats.gpu;
},
});
function attach() {
if (
!scene?.postUpdate?.addEventListener ||
!scene?.preRender?.addEventListener
)
return null;
const tracked = new Map();
const matrix = new Cesium.Matrix4();
const position = new Cesium.Cartesian3();
let epoch = 0;
let sampler, width, height, active, count;
const gpu = createGpu(scene, () => state);
stats.gpu = gpu.stats;
const restore = (visual, entry) => {
if (!alive(entry.owner)) return;
for (const key of entry.keys)
applyCyberSonarPrimitive(visual, 1, false, undefined, key);
};
const restoreAll = () => {
for (const [visual, entry] of tracked) restore(visual, entry);
tracked.clear();
stats.contacts = 0;
stats.updateMs = 0;
};
const visit = (visual, owner, kind, visible) => {
let entry = tracked.get(visual);
if (!visible) {
if (entry) {
restore(visual, entry);
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 (!entry) {
entry = {
owner,
keys:
kind === 'label'
? ['fillColor', 'outlineColor', 'backgroundColor']
: kind === 'marker' && visual.outlineColor
? ['color', 'outlineColor']
: ['color'],
epoch,
};
tracked.set(visual, entry);
}
entry.epoch = epoch;
const factor = active
? sampler.at((x + 1) * width * 0.5, (1 - y) * height * 0.5)
: sampler.neutral;
for (const key of entry.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 (!state?.enabled || !gpu.stats.supported) {
restoreAll();
return;
}
const started = performance.now();
width = scene.canvas.clientWidth;
height = scene.canvas.clientHeight;
sampler = cyberSonarSampler(width, height, state);
active = state.active;
Cesium.Matrix4.multiply(
scene.camera.frustum.projectionMatrix,
scene.camera.viewMatrix,
matrix,
);
epoch++;
count = 0;
visitCyberSonarModels(scene.primitives, visit);
for (const [visual, entry] of tracked)
if (entry.epoch !== epoch) {
restore(visual, entry);
tracked.delete(visual);
}
stats.contacts = count;
stats.updateMs = performance.now() - started;
stats.frames++;
};
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(() => {
removeRender?.();
removeRender = scene.preRender.addEventListener(update);
});
return {
restoreAll,
destroy() {
removeUpdate();
removeRender?.();
gpu.destroy();
restoreAll();
},
};
}
return {
set(next) {
state = next;
if (next.enabled) treatment ??= attach();
else treatment?.restoreAll();
},
diagnostics: () => report,
destroy() {
treatment?.destroy();
treatment = null;
},
};
}
+219
View File
@@ -0,0 +1,219 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { CONTACT_SONAR_INITIAL } from '../contract.js';
import { createCesiumSonar, visitCyberSonarModels } from './sonar.js';
import { createCesiumRender } from './render.js';
const ON = Object.freeze({
...CONTACT_SONAR_INITIAL,
enabled: true,
active: true,
angleDeg: 90,
opacity: 40,
});
// A scene with its frame events, camera and the given primitives.
function sonarScene(primitives, extra = {}) {
let requests = 0;
const scene = {
primitives,
preUpdate: new Cesium.Event(),
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++;
},
...extra,
};
const draw = () => {
scene.preUpdate.raiseEvent();
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
scene.postRender.raiseEvent();
};
return { scene, viewer: { scene }, draw, requests: () => requests };
}
// A model-shaped fixture exercises the actual scene walk without loading a
// glTF or allocating a WebGL context. Rendering remains the browser gate.
function fakeModel(color) {
return Object.create(Cesium.Model.prototype, {
ready: { value: true },
show: { value: true },
color: { value: color.clone(), writable: true },
modelMatrix: { value: Cesium.Matrix4.IDENTITY },
});
}
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('nothing joins the scene until the treatment is first on', () => {
const primitives = new Cesium.PrimitiveCollection();
const original = primitives.update;
const { viewer, scene } = sonarScene(primitives);
const sonar = createCesiumSonar(viewer);
sonar.set({ ...CONTACT_SONAR_INITIAL });
assert.equal(primitives.update, original);
assert.equal(scene.postUpdate.numberOfListeners, 0);
sonar.set({ ...ON });
assert.notEqual(primitives.update, original);
assert.equal(scene.postUpdate.numberOfListeners, 1);
sonar.destroy();
sonar.destroy();
assert.equal(primitives.update, original);
assert.equal(scene.postUpdate.numberOfListeners, 0);
assert.equal(scene.preRender.numberOfListeners, 0);
primitives.destroy();
});
test('native owner colors remain untouched; models take the sweep and get theirs back', () => {
const primitives = new Cesium.PrimitiveCollection({
destroyPrimitives: false,
});
const points = primitives.add(new Cesium.PointPrimitiveCollection());
const point = points.add({ color: new Cesium.Color(0.3, 0.7, 0.5, 0.8) });
const nativeColor = new Cesium.Color(0.2, 0.4, 0.8, 1);
const model = fakeModel(nativeColor);
primitives.add(model);
const { viewer, draw } = sonarScene(primitives);
const sonar = createCesiumSonar(viewer);
try {
for (const active of [true, false, true]) {
for (const opacity of [35, 84, 100]) {
sonar.set({ ...ON, active, opacity });
point.color = new Cesium.Color(0.6, 0.4, 0.2, 0.7);
draw();
assert.equal(point.color.alpha, 0.7);
assert.equal(point.color.red, 0.6);
}
}
assert.notDeepEqual(model.color, nativeColor, 'the model is tinted');
assert.equal(sonar.diagnostics().contacts, 1);
// Off: the model's colour comes back at once, before any frame.
sonar.set({ ...ON, enabled: false });
assert.deepEqual(model.color, nativeColor);
assert.equal(sonar.diagnostics().contacts, 0);
} finally {
sonar.destroy();
points.destroy();
primitives.destroy();
}
});
test('a shader failure turns the treatment off and the next frame restores the models', () => {
const nativeColor = new Cesium.Color(0.2, 0.4, 0.8, 1);
const model = fakeModel(nativeColor);
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);
const { viewer, scene } = sonarScene(primitives);
const sonar = createCesiumSonar(viewer);
try {
assert.equal(sonar.diagnostics().supported, true);
sonar.set({ ...ON, active: false });
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(sonar.diagnostics().supported, false);
assert.equal(frame.commandList[0], nativeCommand);
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
assert.deepEqual(
model.color,
nativeColor,
'the next draw restores native model color',
);
assert.equal(sonar.diagnostics().contacts, 0);
} finally {
sonar.destroy();
points.destroy();
primitives.destroy();
}
});
test('render.setContactSonar merges, hands the sonar a copy and asks for a frame only outside one', () => {
const primitives = new Cesium.PrimitiveCollection();
const { viewer, draw, scene } = sonarScene(primitives);
const reasons = [];
const states = [];
const { render, sonarDiagnostics, destroy } = createCesiumRender(viewer, {
requestRender: (reason) => reasons.push(reason),
createSonar: () => ({
set: (state) => states.push(state),
diagnostics: () => ({ supported: true }),
destroy() {},
}),
});
assert.deepEqual(render.contactSonar(), CONTACT_SONAR_INITIAL);
render.setContactSonar({ enabled: true, angleDeg: 10 });
assert.deepEqual(reasons, ['cyber-sonar']);
assert.deepEqual(states.at(-1), {
...CONTACT_SONAR_INITIAL,
enabled: true,
angleDeg: 10,
});
// The app sets it every frame, in preRender: no request then.
const off = scene.preRender.addEventListener(() =>
render.setContactSonar({ angleDeg: 20 }),
);
draw();
off();
assert.deepEqual(reasons, ['cyber-sonar']);
assert.equal(render.contactSonar().angleDeg, 20);
// An unchanged state is not handed on.
const handed = states.length;
render.setContactSonar({ angleDeg: 20 });
assert.equal(states.length, handed);
assert.throws(() => render.setContactSonar({ range: 0 }), TypeError);
assert.equal(sonarDiagnostics().supported, true);
destroy();
assert.equal(scene.preUpdate.numberOfListeners, 0);
assert.equal(scene.postRender.numberOfListeners, 0);
primitives.destroy();
});
@@ -1,3 +1,7 @@
// The Cyber HUD sonar on Cesium's native dots, icons and glyphs
// (render.setContactSonar, sonar.js): each drawn point or billboard batch
// gets a derived draw command whose shader applies the sweep.
import * as Cesium from 'cesium';
// Narrow, version-tested engine adapter. Cesium has no public custom shader
@@ -137,6 +141,7 @@ export function isCyberSonarCommandVisible(command, frameState, culling) {
/** Work scales with draw commands, not the number of contacts in a collection.
* Source programs, colors, pick IDs, render states and other scenes stay owned
* by Cesium. Only this scene's rendered command slice receives derived shaders.
* `readFrame` returns the sonar state to draw (the contract's ContactSonar).
*/
export function createCyberSonarGpu(scene, readFrame) {
const root = scene.primitives;
@@ -190,7 +195,7 @@ export function createCyberSonarGpu(scene, readFrame) {
}
viewport.x = scene.drawingBufferWidth;
viewport.y = scene.drawingBufferHeight;
uniforms.x = frame.angle;
uniforms.x = frame.angleDeg;
uniforms.y =
(Math.min(viewport.x * 0.4, viewport.y * 0.52) * frame.range) / 100;
uniforms.z = frame.opacity / 100;
@@ -7,7 +7,7 @@ import {
CYBER_SONAR_CESIUM_VERSION,
isCyberSonarCommandVisible,
syncCyberSonarCommand,
} from './cyberSonarGpu.js';
} from './sonarCommands.js';
test('stable commands reuse uniforms and derived pipelines while all native fields remain live', () => {
const native = new Cesium.DrawCommand({
@@ -197,7 +197,7 @@ test('unknown native shaders fail closed without losing native commands', () =>
() => ({
enabled: true,
active: true,
angle: 0,
angleDeg: 0,
range: 100,
opacity: 84,
sector: 24,
@@ -272,7 +272,7 @@ test('program swaps, layer removal and late shader failure release only adapter
() => ({
enabled,
active: true,
angle: 0,
angleDeg: 0,
range: 100,
opacity: 84,
sector: 24,
@@ -1,7 +1,7 @@
import * as Cesium from 'cesium';
import { loadBundledJson } from '../data/bundledJson.js';
import { governorRequestRender } from '../renderGovernor.js';
import { buildStarVertices, STAR_VERTEX_FLOATS } from './stars.js';
import { loadBundledJson } from '../../data/bundledJson.js';
import { governorRequestRender } from '../../renderGovernor.js';
import { buildStarVertices, STAR_VERTEX_FLOATS } from '../../sky/stars.js';
/**
* Space background in the `Scene#skyBox` slot: catalogue stars as point
@@ -18,32 +18,32 @@ export const MILKY_WAY_INTENSITY = 0.4;
export const SKY_ROTATION_EPSILON_SECONDS = 1;
const STAR_PACK_URL = new URL(
'../data/local_data/sky/bright_stars.json',
'../../data/local_data/sky/bright_stars.json',
import.meta.url,
);
const MILKY_WAY_FACE_URLS = Object.freeze({
positiveX: new URL(
'../data/local_data/sky/milkyway_px.webp',
'../../data/local_data/sky/milkyway_px.webp',
import.meta.url,
),
negativeX: new URL(
'../data/local_data/sky/milkyway_nx.webp',
'../../data/local_data/sky/milkyway_nx.webp',
import.meta.url,
),
positiveY: new URL(
'../data/local_data/sky/milkyway_py.webp',
'../../data/local_data/sky/milkyway_py.webp',
import.meta.url,
),
negativeY: new URL(
'../data/local_data/sky/milkyway_ny.webp',
'../../data/local_data/sky/milkyway_ny.webp',
import.meta.url,
),
positiveZ: new URL(
'../data/local_data/sky/milkyway_pz.webp',
'../../data/local_data/sky/milkyway_pz.webp',
import.meta.url,
),
negativeZ: new URL(
'../data/local_data/sky/milkyway_nz.webp',
'../../data/local_data/sky/milkyway_nz.webp',
import.meta.url,
),
});
@@ -149,7 +149,7 @@ export class StarrySky {
/** Matches `SkyBox#show`. */
this.show = true;
this.milkyWayIntensity = MILKY_WAY_INTENSITY;
/** 0..1 multiplier a style sets to hide the glow (see visualEffects.js). */
/** 0..1 multiplier a style sets to hide the glow (see postStages.js). */
this.milkyWayVisibility = 1;
this._requestRender = requestRender;
this._rotation = Cesium.Matrix3.clone(Cesium.Matrix3.IDENTITY);
File renamed without changes.
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { createTerrainRetryPolicy } from './terrainRetry.js';
import { createTerrainRetryPolicy } from '../../maps/terrainRetry.js';
/** Re:Earth / Mapterhorn ellipsoidal quantized mesh, CC BY 4.0. */
export const KEYLESS_TERRAIN_URL =
@@ -20,7 +20,7 @@
* carrying a pick id the layers' click handlers resolve.
*/
import * as Cesium from 'cesium';
import { registerPickOwner } from '../pickRegistry.js';
import { registerPickOwner } from '../../data/pickRegistry.js';
// Round 6: trail ENTITIES are pickable (the old Primitive had
// allowPicking:false). A trail hugs its aircraft, so an unclaimed pick would
@@ -1,10 +1,11 @@
import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
import { StarrySky } from '../sky/starrySky.js';
import { StarrySky } from './starrySky.js';
import { loadPhotorealisticTileset } from '../../maps/google3d.js';
import {
FREE_LOOK_MAX_HEIGHT_METERS,
globeOutOfFrame,
} from '../globeInView.js';
} from '../../globeInView.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing).
@@ -376,6 +377,15 @@ export function installGlobeInViewRecovery(
};
}
/**
* Google's photorealistic 3D tiles for the startup stack, on this Cesium
* (src/maps/google3d.js loadPhotorealisticTileset, which takes the SDK).
* @param {{ googleApiKey?: string, googleTokens?: object | null,
* cesiumToken?: string }} keys
*/
export const loadCesiumPhotorealTileset = (keys) =>
loadPhotorealisticTileset(Cesium, keys);
/** Cesium.Viewer options of the standard globe viewer. */
export function applicationViewerOptions(creditContainer) {
return {
@@ -417,7 +427,7 @@ export function createApplicationViewer({ container, creditContainer }) {
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
// Before any tile builds a draw command: Cesium's per-vertex model
// atmosphere fails to LINK on Apple's Metal backend and kills the
// render loop. See app/atmosphereCompat.js.
// render loop. See atmosphereCompat.js.
applyModelAtmosphereWorkaround(viewer.scene);
viewer.scene.globe.show = false;
viewer.scene.skyAtmosphere.show = true;
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { governorRequestRender } from '../renderGovernor.js';
import { governorRequestRender } from '../../renderGovernor.js';
/** Whole-Earth input limit; explicit camera destinations can go farther. */
export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000;
File renamed without changes.
+38 -17
View File
@@ -232,16 +232,16 @@
* (the maths of the app's Cesium sharpen stage, whose `amount` uniform the
* Sharpen slider sets in 0.1..2.1). Omitted fields keep their value; a
* non-boolean `enabled` or a negative or non-finite `amount` throws a
* TypeError. Off with amount 1 until set. The Cesium engine only keeps the
* state: in Cesium mode the app's own post stage sharpens and mirrors its
* state here (src/ui/visualEffects.js).
* TypeError. Off with amount 1 until set. Cesium draws it as a post stage
* after the styles' (src/globe/cesium/postStages.js); the app's Display
* controls drive it (src/ui/visualEffects.js).
* @property {() => { enabled: boolean, amount: number }} sharpen
* The sharpening last set (a copy).
* @property {readonly string[]} [visualStyles] Optional, with setStyle and
* style (capability `visualStyles`, see engineCapabilities): the
* full-frame styles the engine draws itself, by the app's style names
* (src/styles: retro, surveillance, thermal, anime, noir). Cesium has none:
* in Cesium mode the app's own post stages draw the styles
* (src/styles: retro, surveillance, thermal, anime, noir). Cesium draws
* each as its own post stage, Navara in one pass; the app drives them
* (src/ui/visualEffects.js).
* @property {(name: string, options: { intensity?: number, uniforms?: Record<string, number> }) => void} [setStyle]
* Shows one style at `intensity` (0..1, 0 draws nothing; a crossfade shows
@@ -259,8 +259,8 @@
* bloomStrengthFromIntensity); at or under 0.06 nothing glows, as with
* Cesium's bloom stage, whose easing the engine follows. Omitted fields
* keep their value; a non-boolean `enabled` or a strength outside 0..1
* throws a TypeError. Off at strength 0 until set. Cesium has none: in
* Cesium mode the app drives the scene's own bloom stage.
* throws a TypeError. Off at strength 0 until set. Cesium draws it with the
* scene's own bloom stage, which it gives back on destroy.
* @property {() => { enabled: boolean, strength: number }} [bloom] The bloom
* last set (a copy).
* @property {(options: { coverage?: number | null }) => void} [setCloudCover]
@@ -282,8 +282,9 @@
* off (`active` false) every contact takes the neutral factor. Positions
* and picks are unchanged. Omitted fields keep their value; a bad value
* throws a TypeError. Takes effect on the next drawn frame (a call outside
* a frame requests one). Off until set. Cesium has none: in Cesium mode
* src/cyberSonarScene.js adapts Cesium's own draw commands.
* a frame requests one). Off until set. Cesium adapts its native draw
* commands and tints models (src/globe/cesium/sonar.js); the app hands
* the state over every frame (src/cyberSonarEngine.js).
* @property {() => ContactSonar} [contactSonar] The sonar last set (a copy).
*
* @typedef {object} ContactSonar
@@ -297,6 +298,22 @@
*
* @typedef {{ intensity: number, uniforms: Record<string, number> }} StyleState
*
* @typedef {object} GlobeInput
* @property {(type: InputType, listener: (event: InputEvent) => void, options?: InputOptions) => () => void} on
* As in Cesium, a listener with a modifier fires only while that key is
* held and one without fires only while no modifier is held.
* @property {(enabled: boolean) => void} setCameraInputs Whether the user's
* drag, wheel and touch move the camera (Cesium's enableInputs). Under a
* suspension the choice is kept and applies at the last resume.
* @property {() => () => void} suspendCameraInputs Camera inputs off until
* the returned resume (idempotent): for an owner that holds the camera for
* a while (the cockpit, a gizmo or box drag, a flight a gesture would
* cancel). Suspensions count: inputs stay off until the last one resumes,
* which brings back the state from before the first (Cesium: the
* controller's enableInputs as it was), or what setCameraInputs set since.
* @property {() => boolean} cameraInputsEnabled Whether the user's gestures
* move the camera now: on, and under no suspension.
*
* @typedef {object} PickResult
* @property {any} id The picked entity or object id (a mover's or line's
* pickId); undefined for map tiles.
@@ -638,9 +655,7 @@
* @property {{ preUpdate: EngineEvent, preRender: EngineEvent, postRender: EngineEvent }} frames Listeners receive a timestamp in ms.
* @property {GlobeRender} render
* @property {GlobeSurface} surface
* @property {{ on: (type: InputType, listener: (event: InputEvent) => void, options?: { modifier?: InputModifier }) => () => void, setCameraInputs: (enabled: boolean) => void }} input
* As in Cesium, a listener with a modifier fires only while that key is
* held and one without fires only while no modifier is held.
* @property {GlobeInput} input
* @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits
* @property {GlobeMaps} maps
* @property {GlobeClock} clock
@@ -851,7 +866,12 @@ const SHAPE = {
clampToHeightSupported: isBoolean,
tilesLoaded: isFunction,
},
input: { on: isFunction, setCameraInputs: isFunction },
input: {
on: isFunction,
setCameraInputs: isFunction,
suspendCameraInputs: isFunction,
cameraInputsEnabled: isFunction,
},
credits: { addStatic: isFunction },
clock: { now: isFunction, frameTime: isFunction },
movers: { createSet: isFunction, list: isFunction },
@@ -911,10 +931,11 @@ const memberAt = (engine, path) =>
/**
* Which optional capabilities an engine has: { visualStyles, ... } as
* booleans. A caller that refuses a feature on an engine without a Cesium
* viewer (voice, say) asks here instead: Cesium draws these through the app's
* own stages and reports false. `movers`, `lines`, `ground` and `imagery`
* are required; they are reported too, true for every valid engine.
* booleans. A caller that refuses a feature on an engine without one
* (voice, say) asks here. Cesium and Navara draw the styles, bloom and
* contact sonar; only Navara has cloudCover. `movers`, `lines`, `ground`
* and `imagery` are required; they are reported too, true for every valid
* engine.
*/
export function engineCapabilities(engine) {
const whole = (members, prefix = '') =>
+1 -1
View File
@@ -3,7 +3,7 @@
// bloomStrengthFromIntensity, 0..1) as postprocessing BloomEffect settings,
// and the bloom port createNavaraEngine takes. bloom.js adds the effect.
//
// Cesium's bloom stage (src/ui/visualEffects.js applyBloomIntensity) eases
// Cesium's bloom stage (src/globe/cesium/postStages.js bloomUniforms) eases
// the strength above a 0.06 dead zone (smoothstep) into a lower contrast, a
// brighter offset and a wider blur: more of the frame glows, further out.
// The same eased value drives the threshold, softness, blur radius and
+6 -3
View File
@@ -139,10 +139,13 @@ export function createNavaraInput(
cameraInputs = Boolean(enabled);
sendCameraInputs();
},
/** Off under any suspension, else the setCameraInputs choice. */
cameraInputsEnabled: () => cameraInputs && suspended === 0,
/**
* Navara-only: Navara's spin, zoom and tilt off until the returned
* release (the follow camera takes drag and wheel itself); the app's
* setCameraInputs choice comes back after the last release.
* Navara's spin, zoom and tilt off until the returned release (the
* follow camera takes drag and wheel itself, as do the app's camera
* owners); the app's setCameraInputs choice comes back after the last
* release.
*/
suspendCameraInputs() {
suspended++;
+20
View File
@@ -134,6 +134,26 @@ test('setCameraInputs toggles Navara camera control and user input cancels fligh
]);
});
test('suspendCameraInputs counts and keeps the setCameraInputs choice for the last resume', () => {
const { input, cameraOptions } = setup();
const on = { enableSpin: true, enableZoom: true, enableTilt: true };
const off = { enableSpin: false, enableZoom: false, enableTilt: false };
assert.equal(input.cameraInputsEnabled(), true);
const outer = input.suspendCameraInputs();
const inner = input.suspendCameraInputs();
assert.equal(input.cameraInputsEnabled(), false);
inner();
inner();
assert.deepEqual(cameraOptions.at(-1), off);
input.setCameraInputs(false);
outer();
assert.deepEqual(cameraOptions.at(-1), off);
assert.equal(input.cameraInputsEnabled(), false);
input.setCameraInputs(true);
assert.deepEqual(cameraOptions.at(-1), on);
assert.equal(input.cameraInputsEnabled(), true);
});
test('destroy removes every DOM listener and later subscriptions are inert', () => {
const { canvas, input, destroy } = setup();
input.on('click', () => {});
+1 -1
View File
@@ -2,7 +2,7 @@
// ({ descriptor, credit } for imagery, { id } for terrain) or plain
// descriptions ({ kind, accessToken?, googleApiKey? }); this module builds
// Navara sources and layers from them. The URLs and credits repeat those of
// src/maps/imagery.js and src/maps/terrain.js, which import Cesium.
// src/globe/cesium/mapImagery.js and terrain.js, which import Cesium.
//
// - imagery: OSM and Esri as `raster-tile` layers; Cesium ion imagery (Bing)
// has no Navara path yet and rejects.
+5 -4
View File
@@ -3,10 +3,11 @@
// shaders (movers/moverMesh.js) from uniforms every overlay of a view
// shares. The rings and the sweep itself are the HUD's own (CSS,
// src/cyberSonar.js); this is the contact side, the counterpart of the
// Cesium command adapter (src/cyberSonarGpu.js), with the same maths: each
// contact's factor from its anchor's place on screen against the sweep (the
// sampler of src/cyberSonar.js createCyberSonarSampler), its colour pulled
// toward the scanner tint by that factor and its alpha scaled by it.
// Cesium command adapter (src/globe/cesium/sonarCommands.js), with the same
// maths: each contact's factor from its anchor's place on screen against
// the sweep (the sampler of src/cyberSonar.js createCyberSonarSampler), its
// colour pulled toward the scanner tint by that factor and its alpha scaled
// by it.
// Positions, picks and the instance data stay as the layers set them.
// Models keep their colours (Cesium tints them on the CPU).
+1 -1
View File
@@ -33,7 +33,7 @@ import {
} from 'three';
export const STYLES_EFFECT_KEY = 'gevStyles';
/** Intensity under which a style draws nothing (as VisualEffects disables a stage). */
/** Intensity under which a style draws nothing (as Cesium disables a stage). */
const STYLE_VISIBLE_INTENSITY = 0.001;
const VERTEX_SHADER = /* glsl */ `precision highp float;
+52
View File
@@ -1698,6 +1698,58 @@ export function createContractSuite(
engine.input.setCameraInputs(true);
},
],
[
'input.suspendCameraInputs: counted, the last resume restores the state before the first',
async () => {
const { input } = engine;
const before = input.cameraInputsEnabled();
ensure(
typeof before === 'boolean',
`cameraInputsEnabled() returned ${before}`,
);
try {
input.setCameraInputs(true);
ensure(
input.cameraInputsEnabled(),
'setCameraInputs(true) left them off',
);
const outer = input.suspendCameraInputs();
const inner = input.suspendCameraInputs();
ensure(
typeof outer === 'function' && typeof inner === 'function',
'suspendCameraInputs returned no resume',
);
ensure(!input.cameraInputsEnabled(), 'on under a suspension');
inner();
inner();
ensure(
!input.cameraInputsEnabled(),
'an inner resume (twice) let them on under the outer one',
);
outer();
ensure(input.cameraInputsEnabled(), 'off after the last resume');
input.setCameraInputs(false);
input.suspendCameraInputs()();
ensure(
!input.cameraInputsEnabled(),
'a resume turned on inputs that were off before the suspension',
);
const resume = input.suspendCameraInputs();
input.setCameraInputs(true);
ensure(
!input.cameraInputsEnabled(),
'setCameraInputs(true) lifted a suspension',
);
resume();
ensure(
input.cameraInputsEnabled(),
'the choice made under a suspension did not apply at its resume',
);
} finally {
input.setCameraInputs(before);
}
},
],
[
'credits.addStatic: returns an idempotent remover',
async () => {
+19 -1
View File
@@ -320,6 +320,9 @@ export function createFakeEngine(options = {}) {
let moving = false;
let lastFramePose = null;
let lastChangedPose = null;
// The setCameraInputs choice and the suspensions held over it.
let cameraInputChoice = true;
let cameraSuspensions = 0;
const fake = {
calls: [],
@@ -782,8 +785,23 @@ export function createFakeEngine(options = {}) {
},
setCameraInputs(enabled) {
record('input.setCameraInputs', [enabled]);
fake.cameraInputsEnabled = Boolean(enabled);
cameraInputChoice = Boolean(enabled);
fake.cameraInputsEnabled = cameraInputChoice && !cameraSuspensions;
},
suspendCameraInputs() {
record('input.suspendCameraInputs', []);
cameraSuspensions++;
fake.cameraInputsEnabled = false;
let resumed = false;
return () => {
if (resumed) return;
resumed = true;
record('input.resumeCameraInputs', []);
cameraSuspensions--;
fake.cameraInputsEnabled = cameraInputChoice && !cameraSuspensions;
};
},
cameraInputsEnabled: () => fake.cameraInputsEnabled,
},
credits: {
addStatic(html, creditOptions = {}) {
+8
View File
@@ -129,6 +129,14 @@ test('fake input dispatches to subscribers and off removes them', () => {
assert.throws(() => engine.input.on('wheel', () => {}), /Unknown input type/);
engine.input.setCameraInputs(false);
assert.equal(engine.fake.cameraInputsEnabled, false);
engine.input.setCameraInputs(true);
const resume = engine.input.suspendCameraInputs();
const inner = engine.input.suspendCameraInputs();
assert.equal(engine.input.cameraInputsEnabled(), false);
inner();
resume();
resume();
assert.equal(engine.input.cameraInputsEnabled(), true);
});
test('pick, sampleHeight and worldToWindow results can be scripted', () => {
-5
View File
@@ -1107,9 +1107,6 @@
"That GPX file has no <gpx> document.": "لا يحتوي ملف GPX هذا على مستند <gpx>.",
"That {format} file is not valid XML.": "ملف {format} هذا ليس XML صالحًا.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "يحتوي هذا الملف على صورة غير قابلة للقراءة أو تتجاوز حد 16 ميغابكسل.",
"That KML could not be read.": "تعذّرت قراءة ملف KML هذا.",
"That file has invalid point coordinates.": "يحتوي هذا الملف على إحداثيات نقاط غير صالحة.",
"Ground heights are not available for that file. Try again when the map has loaded.": "ارتفاعات الأرض غير متاحة لهذا الملف. أعد المحاولة بعد تحميل الخريطة.",
"That file took too long to read.": "استغرقت قراءة هذا الملف وقتًا طويلًا جدًا.",
"Nothing to import.": "لا يوجد ما يُستورد.",
"That file is larger than {size} MiB.": "حجم هذا الملف أكبر من {size} MiB.",
@@ -1118,8 +1115,6 @@
"That KMZ holds no KML document.": "لا يحتوي ملف KMZ هذا على مستند KML.",
"That {format} file could not be read.": "تعذّرت قراءة ملف {format} هذا.",
"That file has {count} map objects; the limit is {limit}.": "عدد عناصر الخريطة في هذا الملف {count}، والحد المسموح {limit}.",
"Ground heights could not be read. Try that file again.": "تعذّرت قراءة ارتفاعات الأرض. أعد استيراد الملف.",
"That file has invalid point graphics.": "يحتوي هذا الملف على رسومات نقاط غير صالحة.",
"Map tiles temporarily unavailable": "بلاطات الخريطة غير متاحة مؤقتًا",
"Mapped names temporarily unavailable": "أسماء المواقع المرسومة غير متاحة مؤقتًا",
"Military airfield": "مطار عسكري",
-5
View File
@@ -1075,9 +1075,6 @@
"That GPX file has no <gpx> document.": "Diese GPX-Datei hat kein <gpx>-Dokument.",
"That {format} file is not valid XML.": "Diese {format}-Datei ist kein gültiges XML.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "Diese Datei enthält ein unlesbares Bild oder überschreitet das Bildlimit von 16 Megapixeln.",
"That KML could not be read.": "Diese KML konnte nicht gelesen werden.",
"That file has invalid point coordinates.": "Diese Datei enthält ungültige Punktkoordinaten.",
"Ground heights are not available for that file. Try again when the map has loaded.": "Für diese Datei sind keine Geländehöhen verfügbar. Erneut versuchen, wenn die Karte geladen ist.",
"That file took too long to read.": "Das Lesen dieser Datei hat zu lange gedauert.",
"Nothing to import.": "Nichts zu importieren.",
"That file is larger than {size} MiB.": "Diese Datei ist größer als {size} MiB.",
@@ -1086,8 +1083,6 @@
"That KMZ holds no KML document.": "Diese KMZ enthält kein KML-Dokument.",
"That {format} file could not be read.": "Diese {format}-Datei konnte nicht gelesen werden.",
"That file has {count} map objects; the limit is {limit}.": "Diese Datei hat {count} Kartenobjekte; das Limit ist {limit}.",
"Ground heights could not be read. Try that file again.": "Geländehöhen konnten nicht gelesen werden. Die Datei erneut laden.",
"That file has invalid point graphics.": "Diese Datei enthält ungültige Punktgrafiken.",
"Map tiles temporarily unavailable": "Kartenkacheln vorübergehend nicht verfügbar",
"Mapped names temporarily unavailable": "Kartierte Namen vorübergehend nicht verfügbar",
"Military airfield": "Militärflugplatz",
-5
View File
@@ -2147,9 +2147,6 @@
"That GPX file has no <gpx> document.": "Ese archivo GPX no tiene documento <gpx>.",
"That {format} file is not valid XML.": "Ese archivo {format} no es un XML válido.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "Ese archivo tiene una imagen ilegible o supera el límite de imagen de 16 megapíxeles.",
"That KML could not be read.": "No se pudo leer ese KML.",
"That file has invalid point coordinates.": "Ese archivo tiene coordenadas de puntos no válidas.",
"Ground heights are not available for that file. Try again when the map has loaded.": "Las alturas del terreno no están disponibles para ese archivo. Vuelve a intentarlo cuando el mapa haya cargado.",
"That file took too long to read.": "La lectura de ese archivo tardó demasiado.",
"Nothing to import.": "No hay nada que importar.",
"That file is larger than {size} MiB.": "Ese archivo pesa más de {size} MiB.",
@@ -2158,8 +2155,6 @@
"That KMZ holds no KML document.": "Ese KMZ no contiene ningún documento KML.",
"That {format} file could not be read.": "No se pudo leer ese archivo {format}.",
"That file has {count} map objects; the limit is {limit}.": "Ese archivo tiene {count} objetos en el mapa; el límite es {limit}.",
"Ground heights could not be read. Try that file again.": "No se pudieron leer las alturas del terreno. Vuelve a intentarlo con ese archivo.",
"That file has invalid point graphics.": "Ese archivo tiene gráficos de puntos no válidos.",
"Low-accuracy score < 2%": "Índice de baja precisión < 2%",
"Low-accuracy score 2–10%": "Índice de baja precisión 2–10%",
"Low-accuracy score > 10%": "Índice de baja precisión > 10%",
-5
View File
@@ -225,9 +225,6 @@
"That GPX file has no <gpx> document.": "Ce fichier GPX n'a pas de document <gpx>.",
"That {format} file is not valid XML.": "Ce fichier {format} n'est pas un XML valide.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "Ce fichier contient une image illisible ou dépasse la limite de 16 mégapixels par image.",
"That KML could not be read.": "Impossible de lire ce KML.",
"That file has invalid point coordinates.": "Ce fichier contient des coordonnées de points non valides.",
"Ground heights are not available for that file. Try again when the map has loaded.": "Les altitudes du sol ne sont pas disponibles pour ce fichier. Réessayez une fois la carte chargée.",
"That file took too long to read.": "La lecture de ce fichier a pris trop de temps.",
"Nothing to import.": "Rien à importer.",
"That file is larger than {size} MiB.": "Ce fichier dépasse {size} Mio.",
@@ -236,8 +233,6 @@
"That KMZ holds no KML document.": "Ce KMZ ne contient aucun document KML.",
"That {format} file could not be read.": "Impossible de lire ce fichier {format}.",
"That file has {count} map objects; the limit is {limit}.": "Ce fichier contient {count} objets cartographiques ; la limite est de {limit}.",
"Ground heights could not be read. Try that file again.": "Impossible de lire les altitudes du sol. Réessayez avec ce fichier.",
"That file has invalid point graphics.": "Ce fichier contient des symboles de points non valides.",
"Overpass rate-limited": "Overpass : limite de requêtes atteinte",
"Overpass timed out": "Délai Overpass dépassé",
"Overpass could not complete the query": "Overpass n'a pas pu terminer la requête",
-5
View File
@@ -225,9 +225,6 @@
"That GPX file has no <gpx> document.": "Quel file GPX non ha un documento <gpx>.",
"That {format} file is not valid XML.": "Quel file {format} non è un XML valido.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "Quel file ha un'immagine illeggibile o supera il limite di 16 megapixel per immagine.",
"That KML could not be read.": "Impossibile leggere quel KML.",
"That file has invalid point coordinates.": "Quel file ha coordinate dei punti non valide.",
"Ground heights are not available for that file. Try again when the map has loaded.": "Le quote del terreno non sono disponibili per quel file. Riprova quando la mappa è caricata.",
"That file took too long to read.": "La lettura di quel file ha richiesto troppo tempo.",
"Nothing to import.": "Niente da importare.",
"That file is larger than {size} MiB.": "Quel file supera i {size} MiB.",
@@ -236,8 +233,6 @@
"That KMZ holds no KML document.": "Quel KMZ non contiene alcun documento KML.",
"That {format} file could not be read.": "Impossibile leggere quel file {format}.",
"That file has {count} map objects; the limit is {limit}.": "Quel file ha {count} oggetti sulla mappa; il limite è {limit}.",
"Ground heights could not be read. Try that file again.": "Impossibile leggere le quote del terreno. Riprova con quel file.",
"That file has invalid point graphics.": "Quel file ha simboli dei punti non validi.",
"Overpass rate-limited": "Overpass ha limitato le richieste",
"Overpass timed out": "Overpass è andato in timeout",
"Overpass could not complete the query": "Overpass non ha potuto completare la query",
-5
View File
@@ -219,9 +219,6 @@
"That GPX file has no <gpx> document.": "このGPXファイルには<gpx>ドキュメントがありません。",
"That {format} file is not valid XML.": "この{format}ファイルは有効なXMLではありません。",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "このファイルには読み取れない画像があるか、画像が1,600万画素の上限を超えています。",
"That KML could not be read.": "このKMLを読み取れませんでした。",
"That file has invalid point coordinates.": "このファイルには無効な点座標があります。",
"Ground heights are not available for that file. Try again when the map has loaded.": "このファイルの地表高を取得できません。地図の読み込み後にもう一度お試しください。",
"That file took too long to read.": "ファイルの読み取りに時間がかかりすぎました。",
"Nothing to import.": "インポートするものがありません。",
"That file is larger than {size} MiB.": "このファイルは{size} MiBを超えています。",
@@ -230,8 +227,6 @@
"That KMZ holds no KML document.": "このKMZにはKMLドキュメントが含まれていません。",
"That {format} file could not be read.": "この{format}ファイルを読み取れませんでした。",
"That file has {count} map objects; the limit is {limit}.": "このファイルの地図オブジェクトは{count}個です。上限は{limit}個です。",
"Ground heights could not be read. Try that file again.": "地表高を読み取れませんでした。もう一度このファイルをお試しください。",
"That file has invalid point graphics.": "このファイルには無効なポイントグラフィックがあります。",
"Overpass rate-limited": "Overpassがレート制限中",
"Overpass timed out": "Overpassがタイムアウトしました",
"Overpass could not complete the query": "Overpassがクエリを完了できませんでした",
-5
View File
@@ -225,9 +225,6 @@
"That GPX file has no <gpx> document.": "Esse arquivo GPX não tem documento <gpx>.",
"That {format} file is not valid XML.": "Esse arquivo {format} não é um XML válido.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "Esse arquivo tem uma imagem ilegível ou ultrapassa o limite de imagem de 16 megapixels.",
"That KML could not be read.": "Não foi possível ler esse KML.",
"That file has invalid point coordinates.": "Esse arquivo tem coordenadas de pontos inválidas.",
"Ground heights are not available for that file. Try again when the map has loaded.": "As altitudes do terreno não estão disponíveis para esse arquivo. Tente de novo quando o mapa tiver carregado.",
"That file took too long to read.": "A leitura desse arquivo demorou demais.",
"Nothing to import.": "Nada para importar.",
"That file is larger than {size} MiB.": "Esse arquivo tem mais de {size} MiB.",
@@ -236,8 +233,6 @@
"That KMZ holds no KML document.": "Esse KMZ não contém nenhum documento KML.",
"That {format} file could not be read.": "Não foi possível ler esse arquivo {format}.",
"That file has {count} map objects; the limit is {limit}.": "Esse arquivo tem {count} objetos no mapa; o limite é {limit}.",
"Ground heights could not be read. Try that file again.": "Não foi possível ler as altitudes do terreno. Tente esse arquivo de novo.",
"That file has invalid point graphics.": "Esse arquivo tem gráficos de pontos inválidos.",
"Overpass rate-limited": "Overpass: limite de requisições atingido",
"Overpass timed out": "Tempo esgotado no Overpass",
"Overpass could not complete the query": "Overpass não conseguiu concluir a consulta",
-5
View File
@@ -2181,9 +2181,6 @@
"That GPX file has no <gpx> document.": "В этом файле GPX нет документа <gpx>.",
"That {format} file is not valid XML.": "Этот файл {format} — некорректный XML.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "В файле есть нечитаемое изображение или изображение больше лимита в 16 мегапикселей.",
"That KML could not be read.": "Не удалось прочитать этот KML.",
"That file has invalid point coordinates.": "В файле некорректные координаты точек.",
"Ground heights are not available for that file. Try again when the map has loaded.": "Высоты рельефа для этого файла недоступны. Попробуйте ещё раз, когда карта загрузится.",
"That file took too long to read.": "Файл читался слишком долго.",
"Nothing to import.": "Нечего импортировать.",
"That file is larger than {size} MiB.": "Файл больше {size} МиБ.",
@@ -2192,8 +2189,6 @@
"That KMZ holds no KML document.": "В этом KMZ нет документа KML.",
"That {format} file could not be read.": "Не удалось прочитать этот файл {format}.",
"That file has {count} map objects; the limit is {limit}.": "Объектов на карте в файле: {count}; лимит: {limit}.",
"Ground heights could not be read. Try that file again.": "Не удалось получить высоты рельефа. Попробуйте импортировать файл ещё раз.",
"That file has invalid point graphics.": "В файле некорректное оформление точек.",
"Low-accuracy score < 2%": "Доля неточных < 2%",
"Low-accuracy score 2–10%": "Доля неточных 2–10%",
"Low-accuracy score > 10%": "Доля неточных > 10%",
-5
View File
@@ -228,9 +228,6 @@
"That GPX file has no <gpx> document.": "У цьому файлі GPX немає документа <gpx>.",
"That {format} file is not valid XML.": "Цей файл {format} — некоректний XML.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "У файлі є непридатне для читання зображення або зображення, більше за ліміт 16 мегапікселів.",
"That KML could not be read.": "Не вдалося прочитати цей KML.",
"That file has invalid point coordinates.": "У файлі некоректні координати точок.",
"Ground heights are not available for that file. Try again when the map has loaded.": "Висоти рельєфу для цього файлу недоступні. Спробуйте ще раз, коли карта завантажиться.",
"That file took too long to read.": "Файл читався надто довго.",
"Nothing to import.": "Немає чого імпортувати.",
"That file is larger than {size} MiB.": "Файл більший за {size} МіБ.",
@@ -239,8 +236,6 @@
"That KMZ holds no KML document.": "У цьому KMZ немає документа KML.",
"That {format} file could not be read.": "Не вдалося прочитати цей файл {format}.",
"That file has {count} map objects; the limit is {limit}.": "Обʼєктів на карті у файлі: {count}; ліміт: {limit}.",
"Ground heights could not be read. Try that file again.": "Не вдалося отримати висоти рельєфу. Спробуйте імпортувати файл ще раз.",
"That file has invalid point graphics.": "У файлі некоректне оформлення точок.",
"Overpass rate-limited": "Overpass обмежує частоту запитів",
"Overpass timed out": "Минув час очікування Overpass",
"Overpass could not complete the query": "Overpass не зміг завершити запит",
-5
View File
@@ -219,9 +219,6 @@
"That GPX file has no <gpx> document.": "该 GPX 文件没有 <gpx> 文档。",
"That {format} file is not valid XML.": "该 {format} 文件不是有效的 XML。",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "该文件包含无法读取的图像,或图像超过 1600 万像素上限。",
"That KML could not be read.": "无法读取该 KML。",
"That file has invalid point coordinates.": "该文件的点坐标无效。",
"Ground heights are not available for that file. Try again when the map has loaded.": "无法获取该文件的地面高度。请在地图加载完成后重试。",
"That file took too long to read.": "读取该文件耗时过长。",
"Nothing to import.": "没有可导入的内容。",
"That file is larger than {size} MiB.": "该文件大于 {size} MiB。",
@@ -230,8 +227,6 @@
"That KMZ holds no KML document.": "该 KMZ 中没有 KML 文档。",
"That {format} file could not be read.": "无法读取该 {format} 文件。",
"That file has {count} map objects; the limit is {limit}.": "该文件有 {count} 个地图对象,上限为 {limit} 个。",
"Ground heights could not be read. Try that file again.": "无法读取地面高度。请重新导入该文件。",
"That file has invalid point graphics.": "该文件的点样式无效。",
"Overpass rate-limited": "Overpass 已被限流",
"Overpass timed out": "Overpass 超时",
"Overpass could not complete the query": "Overpass 无法完成查询",
-5
View File
@@ -219,9 +219,6 @@
"That GPX file has no <gpx> document.": "該 GPX 文件沒有 <gpx> 文檔。",
"That {format} file is not valid XML.": "該 {format} 文件不是有效的 XML。",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "該文件包含無法讀取的圖像,或圖像超過 1600 萬像素上限。",
"That KML could not be read.": "無法讀取該 KML。",
"That file has invalid point coordinates.": "該文件的點座標無效。",
"Ground heights are not available for that file. Try again when the map has loaded.": "無法獲取該文件的地面高度。請在地圖加載完成後重試。",
"That file took too long to read.": "讀取該文件耗時過長。",
"Nothing to import.": "沒有可導入的內容。",
"That file is larger than {size} MiB.": "該文件大於 {size} MiB。",
@@ -230,8 +227,6 @@
"That KMZ holds no KML document.": "該 KMZ 中沒有 KML 文檔。",
"That {format} file could not be read.": "無法讀取該 {format} 文件。",
"That file has {count} map objects; the limit is {limit}.": "該文件有 {count} 個地圖對象,上限為 {limit} 個。",
"Ground heights could not be read. Try that file again.": "無法讀取地面高度。請重新導入該文件。",
"That file has invalid point graphics.": "該文件的點樣式無效。",
"Overpass rate-limited": "Overpass 已被限流",
"Overpass timed out": "Overpass 超時",
"Overpass could not complete the query": "Overpass 無法完成查詢",
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more