Fix the review findings across the engines, layers, data and QA

Navara now matches Cesium where the review found it diverging: a flight
holds the camera inputs, camera.changed counts turns as Cesium does,
render requests made during a frame survive it, follow pinches zoom,
ground widths follow the pixel ratio and Cyber sonar reaches glyph caches
and models. On Cesium, a staged imagery swap keeps loading tiles, setView
replaces a running flight, and a cleared weather detail goes away.

The contract gains MoverSet.failed, maps.terrainRevision,
render.maxTextureSize and surface.sampleTerrainMostDetailed on every
engine, so layers retry failed models, key road heights by terrain,
size weather textures to the GPU and place Bhote Koshi cards on detailed
terrain.

Engine switches keep the live view, follows, focus, layer options and
cards: a pick hands over the exact pose, applies no address options and
pans no carried-over card; a history visit still applies its entry.

Layers put GhostMaps pins, cable landings, OSM Places and directions on
the measured surface without per-frame sampling, draw the Cockpit contact
dot, hide disabled CCTV on attach, and keep ALPR running outside secure
contexts. KML imports draw raised polygon fills, rotated and coloured
ground overlays and north-referenced icon headings, and report files
with nothing to draw.

QA scripts read the contract instead of Cesium internals (test:track is
109/109 again), wait for the camera to rest before clicking, and give
cold switches and parked engines realistic time. Docs follow the code.
This commit is contained in:
German Khokhlov committed 2026-11-09 21:34:42 +04:00
1 parent 69fbf5b38d
commit 0ef29781e5
276 files changed
+7217 -1517

No files matched your search

+2 -2
View File
@@ -5,7 +5,7 @@ God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the
How to read this:
- **The non-permissive datasets are carved out, not omitted.** Some bundled data (e.g. TeleGeography, CC BY-NC-SA) isn't MIT-compatible. Rather than hide it, we **bundle it with a clear license carve-out** so the app works out of the box — but it stays under the provider's terms.
- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder.
- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder. **EOX Sentinel-2 cloudless 2020** is also **NonCommercial** (CC BY-NC-SA 4.0): remove that basemap or obtain a commercial licence from EOX.
- **Attribution is shown in-app** and listed here. Keep it intact. The required Google/Cesium credit renders on the on-globe credit line (bottom-left, `#cesium-credits`), and every per-layer credit below is registered into the expandable **"Data attribution"** lightbox on that line (`src/data/dataCredits.js` → `viewer.creditDisplay.addStaticCredit`). Both stay visible in clean-view and recording modes.
- **Bundled model attribution lives beside the model files.** [`public/models/README.md`](public/models/README.md) records each shipped model's creator, source, license, and modification status.
@@ -33,7 +33,7 @@ How to read this:
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
| **EOX Sentinel-2 cloudless 2020** | Optional satellite basemap in Cesium and Navara. Navara uses daylight for terrain inspection. | [EOX Maps](https://maps.eox.at/) and [imagery licence](https://cloudless.eox.at/) | Sentinel-2 cloudless 2020 by EOX IT Services GmbH (contains modified Copernicus Sentinel data 2020) |
| **EOX Sentinel-2 cloudless 2020** | Optional satellite basemap in Cesium and Navara. Navara uses daylight for terrain inspection. | [EOX Maps](https://maps.eox.at/); [CC BY-NC-SA 4.0](https://cloudless.eox.at/license-non-commercial). Commercial use requires a commercial licence from EOX. | [EOxCloudless](https://cloudless.eox.at/) by [EOX IT Services GmbH](https://eox.at/) (Contains modified Copernicus Sentinel data 2020) |
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
+2 -1
View File
@@ -52,7 +52,8 @@ resolved dependency inventory remains in `package-lock.json`.
are adapted from `@cesium/engine/Source/Core` (`Math.js`, `Ellipsoid.js`,
`scaleToGeodeticSurface.js`, `Cartographic.js`, `Cartesian2.js`,
`Cartesian3.js`, `Matrix3.js`, `Matrix4.js`, `Quaternion.js`,
`Transforms.js`) and say so in their first
`Transforms.js`, `BoundingSphere.js`, `EllipsoidGeodesic.js`, `JulianDate.js`,
`Simon1994PlanetaryPositions.js`) and say so in their first
line. The changes: the classes' static functions became module functions,
vectors, geodetic positions and quaternions became plain-data instances of
module-private constructors (own enumerable fields only, no methods),
+25 -3
View File
@@ -6,6 +6,9 @@
* PANEL_BASE next to the app.
*/
import cesium from 'vite-plugin-cesium';
import { fileURLToPath } from 'node:url';
// The panel loads these paths (src/tools/globePanel.js); a test keeps the
// two in step.
export const PANEL_BASE = '/panel/';
@@ -18,6 +21,27 @@ export function panelBuildConfig(config) {
return {
...config,
base: PANEL_BASE,
plugins: (config.plugins ?? []).map((plugin) =>
plugin?.name === 'vite-plugin-cesium' ? cesium() : plugin,
),
resolve: {
...config.resolve,
alias: [
// The panel runs Cesium: the Navara mode and Navara's wind mesh
// (the panel's only paths to Navara and three) become stubs.
{
find: /^(?:\.\/|.*\/app\/)navaraMode\.js$|\/globe\/navara\/windFlowMesh\.js$/,
replacement: fileURLToPath(
new URL('./panelNavara.js', import.meta.url),
),
},
...(Array.isArray(config.resolve?.alias)
? config.resolve.alias
: Object.entries(config.resolve?.alias ?? {}).map(
([find, replacement]) => ({ find, replacement }),
)),
],
},
build: {
...config.build,
outDir: PANEL_OUT_DIR,
@@ -26,9 +50,7 @@ export function panelBuildConfig(config) {
cssCodeSplit: false,
rollupOptions: {
...config.build?.rollupOptions,
// The panel inlines every dynamic import into its one script, the
// experimental Navara mode included, so the guard that keeps Navara
// out of the app's initial chunks does not apply here.
// The panel is a single chunk; its alias excludes the Navara engine.
plugins: (config.build?.rollupOptions?.plugins ?? []).filter(
(plugin) => plugin?.name !== 'gev-initial-chunk-guard',
),
+12
View File
@@ -0,0 +1,12 @@
// The panel host transports Cesium assets; Navara workers need a web page.
export function createNavaraScene() {
throw new Error('Navara is unavailable in the panel');
}
export function navaraToolBackend() {
throw new Error('Navara is unavailable in the panel');
}
export function createWindFlowMesh() {
throw new Error('Navara is unavailable in the panel');
}
+41
View File
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createBrowserViteConfig } from './vite.js';
import { panelBuildConfig } from './panel.js';
test('panel HTML lists Cesium assets for the host resource loader', () => {
const config = panelBuildConfig(createBrowserViteConfig());
const plugin = config.plugins.find(
({ name }) => name === 'vite-plugin-cesium',
);
plugin.config(config, { command: 'build' });
assert.equal(typeof plugin.transformIndexHtml, 'function');
const tags = plugin.transformIndexHtml();
assert.ok(tags.some(({ attrs }) => attrs.src === '/panel/cesium/Cesium.js'));
assert.ok(
tags.some(
({ attrs }) => attrs.href === '/panel/cesium/Widgets/widgets.css',
),
);
});
test('the panel replaces Navara entry points and retains browser aliases', () => {
const browser = createBrowserViteConfig();
const config = panelBuildConfig(browser);
const alias = config.resolve.alias.find(({ find }) =>
find.test('./navaraMode.js'),
);
assert.ok(alias.replacement.endsWith('/build/panelNavara.js'));
// Navara's wind mesh imports three; the Cesium wind backend stays.
assert.ok(alias.find.test('../../globe/navara/windFlowMesh.js'));
assert.equal(alias.find.test('../../globe/cesium/windFlow.js'), false);
assert.ok(config.resolve.alias.includes(browser.resolve.alias[0]));
});
test('Three imports and requires share its ESM entry', () => {
const { resolve } = createBrowserViteConfig();
assert.deepEqual(resolve.dedupe, ['three']);
const alias = resolve.alias.find(({ find }) => find.test('three'));
assert.ok(alias.replacement.endsWith('/three/build/three.module.js'));
assert.equal(alias.find.test('three/addons/loaders/GLTFLoader.js'), false);
});
+14
View File
@@ -199,6 +199,20 @@ export function createBrowserViteConfig({
// A production build must not clean the dependency cache a running dev
// server is still serving optimized module URLs from.
...(command === 'build' ? { cacheDir: 'node_modules/.vite-build' } : {}),
resolve: {
dedupe: ['three'],
alias: [
{
find: /^three$/,
replacement: fileURLToPath(
new URL(
'../node_modules/three/build/three.module.js',
import.meta.url,
),
),
},
],
},
optimizeDeps: {
// First reached through the SDR worker or a dynamic import. Pre-bundle
// them at startup so first use cannot invalidate already-transformed
+14 -7
View File
@@ -80,10 +80,17 @@ self-package imports, symlinks and unreachable files.
Runtime SDK imports are restricted by directory, including unreachable modules:
`cesium` and `@cesium/*` belong in `src/globe/cesium/`; `three`,
`postprocessing` and `@navaramap/*` belong in `src/globe/navara/`. Package
subpaths, re-exports and literal dynamic imports obey the same rule. Tests and
Node test support retain their existing exemptions. The production script
loader is `src/app/loadCesium.js`; it imports no SDK package and needs no
exception. There is no blanket exception for layer `render/` directories.
subpaths, re-exports and literal dynamic imports obey the same rule, and the
`<script>` blocks of Vue components count as runtime modules. A module outside
the Navara engine reaches it only through `import()`: a static import of the
same specifier fails even beside a dynamic one. Tests and Node test support
retain their existing exemptions. The production script loader is
`src/app/loadCesium.js`; it imports no SDK package and needs no exception, and
it adds no `widgets.css` when the page's markup has already run `Cesium.js`
(the MCP panel). The panel build replaces `src/app/navaraMode.js` and
`src/globe/navara/windFlowMesh.js` with stubs (`build/panelNavara.js`), so the
panel carries no Navara or three. There is no blanket exception for layer
`render/` directories.
The temporary `engineImportExceptions` list in `scripts/package-boundaries.json`
contains exact file/package pairs with reasons. The checker reports the used
@@ -579,9 +586,9 @@ services into each family factory. Standalone provider selection lives in
dependency graph, excluding standalone setup and compatibility layer instances.
`src/app/layers/renderers.js` is the layer manifest. Its lazy display loaders
resolve the shared factories exported by `displays.js`; `needs` names required
contract primitives. The only display backend options are wind streamlines,
loaded from the engine folders. A missing wind backend leaves the raster field
resolve the shared factories exported by `displays.js` through `display`. All
five drawing primitives are required by the globe contract. The only display
backend options are wind streamlines, loaded from the engine folders. A missing wind backend leaves the raster field
available. Wildlife Sightings draws itself through the contract; Sea Marks and
Ski Pistes use their overlay controller. No layer display imports an engine SDK.
`displayRehearsal.test.mjs` loads every manifest display on the fake engine with
+139 -117
View File
@@ -166,6 +166,18 @@ one application, and only the globe-bound parts move to the next engine.
Measured in headless Chrome (Dubai, 1280×800): Navara 1.6 GiB at DPR 1 and
1.8 GiB at DPR 2, Navara HD 1.8 and 2.7 GiB, Cesium 0.93 and 1.08 GiB
(Navara used 1.95 and 3.0 GiB before).
- **Choosing the engine.** A choice read from storage (no `?engine=`) that
fails to start opens Cesium instead, stores Cesium in `gev.engine` and the
address, and says so in a toast ("Globe engine failed to start. Running
Cesium: …"). An explicit `?engine=` that fails keeps the error and the
engine picker on the loading screen, so another choice retries without a
reload. Back and Forward to an address without `?engine=` use the engine
the page started with, not a `gev.engine` another tab has changed since.
A switch that fails and leaves the previous engine running names that
engine in its toast. The Globe Engine picker is a compact field that keeps
keyboard focus while a switch runs (`aria-disabled`, `aria-busy`); a
hidden status region announces "Initializing systems..." and the new
engine.
- **Memory.** The canvas context has no antialias (Navara draws into its own
targets and copies the result), the cloud shadow maps use the low preset's
256² and 2 cascades, and the tile cache budget is the screen-sized targets
@@ -176,40 +188,32 @@ one application, and only the globe-bound parts move to the next engine.
`Pass.fullscreenGeometry` from postprocessing is disposed after the view,
which otherwise pins the canvas through a three listener.
- **The engine contract** is `src/globe/contract.js`. `src/globe/cesium/*` and
`src/globe/navara/*` implement it, `src/globe/testing/fakeEngine.js` stands
in for tests, and `src/globe/testing/contractSuite.mjs` holds the checks
every engine must pass. `npm test` runs them on the fake engine;
- **The engine contract** is `src/globe/contract.js`; its drawing primitives
(movers, lines, ground, imagery, meshes), scene services and how to add an
engine are in [Globe primitives](GLOBE-PRIMITIVES.md). `src/globe/cesium/*`
and `src/globe/navara/*` implement it, `src/globe/testing/fakeEngine.js`
stands in for tests, and `src/globe/testing/contractSuite.mjs` holds the
checks every engine must pass. `npm test` runs them on the fake engine;
`node scripts/qa-contract-suite.mjs <origin>` runs them in headless Chrome
on the real Cesium and Navara of a keyless dev server, with a real pointer
and real canvases; checks of state the app drives itself (contact sonar)
are skipped and named. `movers` and `lines` are required of every engine: Cesium
draws them with billboards, points, models and polyline primitives
(`src/globe/cesium/movers.js`, `lines.js`).
- **Fills on the ground** (`engine.ground`) are required of every engine
too. They come in sets of polygons with holes, discs, lon/lat cells or
corridors (a width in px or m), draped over terrain and 3D tiles. Each
item has its own fill, outline and `pickId`. A set has a draw `order`, and
shows, hides and replaces its items in bulk.
- A pixel corridor can take Cesium's old looks: a `glow`
(PolylineGlow's falloff), an alpha `pulse` and a flowing `dash` (the
annotation route's). The GPU works them out from the time each frame;
while a shown set has a pulse or a dash, the engine draws a frame after
every frame, as Cesium's continuous render did. Traffic's jam heat
lines glow and pulse, and the Directions route's dashes flow (it also
holds continuous render again while drawn).
- Cesium draws them with batched `GroundPrimitive` and
`GroundPolylinePrimitive`; a corridor with a look draws through one
material of its own (`GevGroundCorridor`), which drew the jam lines and
the route pixel for pixel as the old PolylineGlow and route flow
materials did.
- Navara draws them in one screen pass classified on the scene depth
(`src/globe/navara/ground/`). A corridor set is tiles of binned
segments (`corridors.js`): 10,000 corridors draw in about 12 ms still
and 14 ms moving, where Cesium takes 23 ms (the chunks before took 151
and 196 ms).
- `scripts/qa-contract-suite.mjs --only=ground:` checks ground pixels and corridor looks
on both engines.
and real canvases (`--only=ground:` and `--only=imagery:` check ground and
imagery pixels); checks of state the app drives itself (contact sonar) are
skipped and named. Among its members: `surface.sampleTerrainMostDetailed`
(terrain heights at the finest level, fetched where not loaded),
`maps.terrainRevision()` (changes when the terrain shown is replaced),
`render.maxTextureSize()` and `MoverSet.failed()` (a model or atlas that
will not load). Navara also has Cesium's `contactSonarDiagnostics()`,
outside the contract.
- **Navara's ground and imagery passes.** Navara draws ground fills and
corridors in one screen pass classified on the scene depth
(`src/globe/navara/ground/`): a corridor set is tiles of binned segments
(`corridors.js`), and 10,000 corridors draw in about 12 ms still and 14 ms
moving, where Cesium takes 23 ms (the chunks before took 151 and 196 ms).
Imagery layers draw in one screen pass after tone mapping, classified on
the scene depth (`src/globe/navara/imagery/`); the Active Fires heatmap is
mipmapped at orbit scale. Sea Marks and Ski Pistes keep Navara's native
raster path for tile detail and use `engine.imagery` on engines without
`maps.addOverlay`.
- **Ground and input parity.** Space Missions draws its 500 m launch-pad
zone with the radial ground material: faint fill, bright soft rim and
emission, classified on terrain and tiles. Transit draws its age-banded
@@ -232,43 +236,19 @@ one application, and only the globe-bound parts move to the next engine.
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
its alpha, show, split side and draw `order`, and one split position per
engine (`docs/GLOBE-PRIMITIVES.md`).
- Cesium draws each as an `ImageryLayer` on the imagery stack of the
surface shown (the globe, or the shown 3D tileset's), and follows it
(`src/globe/cesium/imagery.js`).
- Navara draws them in one screen pass after tone mapping, classified on
the scene depth (`src/globe/navara/imagery/`).
- Active Fires' far-zoom heatmap, Recent Imagery, weather, the wind
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.
- 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.
- `scripts/qa-contract-suite.mjs --only=imagery:` checks imagery pixels
on both engines. Navara mipmaps the heatmap at orbit scale.
- **Engine coupling.** `node scripts/report-engine-coupling.mjs` counts the
engine-specific code outside the engines: renderer lines per engine, Cesium
imports, `viewer` mentions, engine-name checks, raw Navara reads, Navara
ground passes, Cesium SDK loads and `ScreenSpaceEventHandler` users.
`scripts/engineCoupling.test.mjs` (in `npm test`) fails when a count grows
past `scripts/engine-coupling-baseline.json`.
- **SDK boundary and display rehearsal.** The import-direction gate permits
Cesium packages only in `src/globe/cesium/` and three, postprocessing and
Navara packages only in `src/globe/navara/`. The measured temporary exceptions
in `scripts/package-boundaries.json` are the three Bhote Koshi model modules:
event, locator and embedded media still build Cesium property records. The
gate rejects unused exceptions. `src/app/layers/displayRehearsal.test.mjs`
loads all 35 display entries from the manifest on a fake engine with an
unknown kind and no native extensions, draws fixtures, hides or releases them,
and checks primitives, subscriptions and service records before engine
destruction. The three self-drawing entries are named skips. This is a
display-level rehearsal; application kind registration and native debug
access remain work.
- **Display rehearsal.** `src/app/layers/displayRehearsal.test.mjs` loads all
35 display entries from the manifest on a fake engine with an unknown kind
and no native extensions, draws fixtures, hides or releases them, and checks
primitives, subscriptions and service records before engine destruction.
The three self-drawing entries are named skips. This is a display-level
rehearsal; application kind registration and native debug access remain
work.
- **Scene transition ownership.** `src/app/sceneHost.js` serializes engine
requests, application attachment and direct shell calls. One transaction
covers handoff, renderer release, candidate acquisition, attachment, view
@@ -277,39 +257,25 @@ one application, and only the globe-bound parts move to the next engine.
hidden-tab settling and parked-renderer reuse retain their existing behavior.
A replacement application registers fresh parts on the same transition owner;
a shell used alone creates its own scene host.
- **Scene pick ownership.** Each attachment owns `scene.services.picking` and
`scene.services.selection` (`src/data/sceneServices.js`). Layers and displays
resolve these services with their attached engine; simultaneous engines do
not share owner predicates or click subscriptions. Detach releases both
services before the renderer is parked. Reattachment creates fresh services,
while retained layers register their current picks again.
- **Scene render demand.** `scene.services.render` owns the attachment's holds
and recent idle requests. It drives `engine.render` on both engines;
Cesium implements its render mode directly. Retained layers identify their
attached engine, and scene callbacks capture their governor. Teardown clears
only that scene's holds; an old service cannot release a replacement's hold.
- **Scene world overlays.** `scene.services.overlay` owns each attachment's
label sources, paint lanes, collision domains, canvases, observers, timers
and accessible actions. Both canvases live inside the engine container;
detection remains outside the card root to preserve its blend. Retained
models resolve their attached engine, and the shell reconnects its action
subscription after switching. Destroyed services and old lane handles stay
inert; a parked engine receives a new host. Shared font measurements live
until the last host detaches.
- **Scene tracked readouts.** `scene.services.trackedReadout` owns the follow
subscription, selected card, active entry ID and follow-label cache for one
attachment. It publishes only into that scene's overlay. Selection records
and notifications belong to `scene.services.context`; publishers resolve
their attached engine. The existing page context slot and selection events
remain available to page consumers with the same payloads. Destroyed readouts
and context services stay inert; parked reattachment creates fresh services.
Engine handoff carries the retained page selection into the incoming context;
rebuilding its marker updates the readout without another page selection event.
Readout initialization leaves the selection intact while its outgoing display
is hidden and its incoming layer waits for camera restoration.
Label text, live visual anchors and follow-camera anti-jitter are unchanged.
- **Scene services per attachment.** Each attachment owns its picking,
selection, render demand, world overlay, tracked readout and context
services (`src/data/sceneServices.js`; described under Input ownership,
Render demand and World overlays in [Globe primitives](GLOBE-PRIMITIVES.md)).
On a switch, detach releases them before the renderer is parked and a
reattachment, parked engines included, creates fresh ones; retained layers
register their picks again and resolve their attached engine, an old
service cannot release a replacement's render hold, and destroyed services
stay inert. The shell reconnects its overlay action subscription; shared
font measurements live until the last overlay host detaches. Engine handoff
carries the retained page selection into the incoming context, and readout
initialization leaves it intact while the outgoing display is hidden and
the incoming layer waits for camera restoration. Label text, live visual
anchors and follow-camera anti-jitter are unchanged.
- **The session owns the state.** Catalog, layer models, sources, selection,
camera mode and URL state outlive a switch. A layer that is a session model
camera mode and URL state outlive a switch. A picker switch hands the
outgoing camera's exact pose to the incoming one (the address rounds it to
1e-4°, whole metres and whole degrees); a history visit takes the entry's
view. A layer that is a session model
gets `attachRenderer(viewer, { engine })` / `detachRenderer()` instead of being rebuilt,
so its data is not fetched again. The outgoing globe is parked
(`src/standalone/rendererCache.js`), so switching back within three minutes
@@ -317,6 +283,52 @@ one application, and only the globe-bound parts move to the next engine.
context lost, which frees about 1.1 GiB for a parked Navara; switching back
then builds it cold (1.5-2 s). `?parkedMs=` shortens the delay for QA
(`scripts/qa-strict-parked-release.mjs`).
- **Navara camera and drawing parity.** A render requested during a frame
draws the next frame, so the wind animates under a still camera. A flight
turns camera input off until it ends; a gesture no longer interrupts it.
`setView` cancels a running flight on both engines. Heading and roll
changes raise `camera.changed`, as on Cesium. 3D models take the Cyber
sonar, which draws at its cadence only while contacts are on screen. Ground
items are picked at the shown 3D tiles under Google 3D, else at the
terrain, by a CPU ray (Navara's depth read answers with the near plane
right after a click). Ground corridor and outline widths are CSS pixels at any device
pixel ratio (Navara HD). Tiled imagery draws across the antimeridian and
replans its tiles on a resize. A follow takes a two-finger pinch. Navara
does not yet refresh an ion terrain token on a 401, nor fall back to
OpenStreetMap when base imagery tiles fail after the source was built
(Cesium does both); its package exposes no hook for either.
- **Layer heights and recovery after a switch.** S2 GhostMaps icons are
sprite movers without depth test that stand on the surface: the terrain on
the globe, a sample of the 3D tiles under Google 3D. Only icons on screen
are measured, at most 16 and 4 ms a pass, again after the camera moves and
tiles load; a height that holds is not written again, so a parked camera
asks for no frames. Submarine cable landing markers stand on the surface by
the same rule and draw over the scene within 2.5 km of the camera. OSM Places refines heights only for places on
screen, within a per-pass budget, after camera moves and tile loads, and
drops implausible samples. Directions' A/B markers and Street Level's cones
move again once tiles load, under Google 3D too. Traffic's road heights are
sampled again when the terrain changes (`maps.terrainRevision`), and cars
on roads below the ellipsoid are not culled by the WGS84 horizon. The
weather shell halves its images (to 1024 px wide) when the GPU's texture
limit is under 4096 (`render.maxTextureSize`). A CCTV layer that was off
stays hidden after a switch, and the active camera's obstruction probe runs
again once the new engine's surface is ready. A tracked aircraft's glTF
that the engine did not load is loaded again in a new mover set after a
backoff and given up after a failure budget; far contacts in Cockpit draw
as dots from their own sprite set.
- **Layer state and cards after a switch.** A history visit across engines
applies the visited entry's layer options that differ from the live
layer (Wind's model, say); equal options are left alone, so a plain switch
writes no layer parameters. The Bhote Koshi evidence cards are published
to the incoming engine's overlay. On Navara their anchors stand on the
terrain sampled at its finest level; where that fails they start at the
pack's elevations and follow the terrain loaded under a camera within
25 km. A followed aircraft's card anchors to the model drawn
(`gevVisualPosition`), on both engines. Turning a layer off ends its camera
arrival correction on every engine. North-up and tilt during a follow work
on both engines and keep the follow: Cesium sets the frame through
`lookAtTransform` in its EntityView frame, Navara through the follow's
`retarget`.
- **Shared layer displays.** Every manifest display draws through the globe
contract from `render/display.js`; the aircraft family shares its display
implementation. There are 35 display entries (including the three weather
@@ -343,12 +355,15 @@ one application, and only the globe-bound parts move to the next engine.
remain on their existing renderers pending bounded classification support;
the Cesium implementations live in `src/globe/cesium/annotations/`.
- **One layer manifest.** `LAYER_MANIFEST` in `src/app/layers/renderers.js`
says how each directory layer draws: its display with the contract
primitives it needs (and an engine-specific backend option keyed by engine
kind: wind streamlines), a layer that
draws itself through the contract (Wildlife Sightings), or, without a
display, its drawing per engine kind. Weather, wind imagery and Bhote Koshi
use shared displays; Sea Marks and Ski Pistes drape themselves on both. Every layer shows on both engines. The displays and wind backend load through `loadLayerRenderers(kind)`, awaited before the engine's catalog. `rendererFor(id, engine, options)` answers synchronously after that.
says how each directory layer draws: its shared `display` (with an optional
engine-specific `backends` option keyed by engine kind: wind streamlines), or
a layer that draws itself through the contract (Wildlife Sightings). Every
engine implements all five drawing primitives, so the manifest lists no
primitives a display needs and registers no supported engine kinds; Sea
Marks and Ski Pistes drape themselves through their overlay controller.
Every layer shows on both engines. The displays and wind backend load
through `loadLayerRenderers(kind)`, awaited before the engine's catalog.
`rendererFor(id, engine, options)` answers synchronously after that.
- **One tools tree.** `src/app/tools.js` attaches the same tools to every
engine (`attachEngineTools`): the annotation board, Draw, Import, the
Recent Imagery box and panel, the render governor, the scope mask,
@@ -367,9 +382,13 @@ one application, and only the globe-bound parts move to the next engine.
`bitExact.test.mjs` compares with Cesium bit for bit.
**Invariant: no module reads Cesium while it loads**
(`scripts/check-cesium-load-time.mjs`, run by `npm test`).
- **Boundaries.** Cesium is imported only from `render/cesium*.js` and
`src/globe/cesium/*`; three, postprocessing and Navara only from `navara*`
modules, which load through `import()`. `build/initialChunkGuard.js` fails
- **Boundaries.** The import-direction gate keeps Cesium packages in
`src/globe/cesium/` and three, postprocessing and Navara packages in
`src/globe/navara/`, which loads only through `import()`; the rules are in
[Code boundaries](CODE-BOUNDARIES.md). The measured temporary exceptions in
`scripts/package-boundaries.json` are the three Bhote Koshi model modules
(event, locator and embedded media still build Cesium property records),
and the gate rejects unused exceptions. `build/initialChunkGuard.js` fails
the build when one of them reaches an initial chunk, and
`npm run check:main-chunk` holds the main chunk to its gzip budget
(`scripts/main-chunk-baseline.json`). Only `src/globe/navara/internals.js`
@@ -604,7 +623,10 @@ style, map, marks and an aircraft or satellite to follow, written in the
share-link format (`gods-eye-view/view`). `show_in_gods_eye_view` shows a view as live
God's Eye View inside clients that display MCP Apps, and as a link everywhere.
The panel runs the app's panel build (`npm run build:panel`, served at
`/panel/`) and loads it, with its data, through the MCP server, so it works
`/panel/`), a Cesium-only build: its HTML loads `Cesium.js` and
`widgets.css`, and stubs keep Navara and three out of its scripts
(`build/panelNavara.js`); its size after that change has not been measured.
The MCP server loads it, with its data, so it works
in Claude Desktop and in the Codex and ChatGPT desktop apps with a local
server and no HTTPS. `?embed=1` shows the app as the globe alone and takes
new views from the page that frames it; framing is off unless
@@ -5092,14 +5114,14 @@ zero-duration journey or immediately relocate a visible vehicle.
**Selected history.** Selecting one vehicle draws its available recent path,
clipped to the displayed time. The browser retains up to 15 minutes and 128
fixes per vehicle, under a global 32 MiB history budget. Capacity pressure may
shorten that history. Only the selected vehicle has a trail, drawn with engine.lines on every
engine: completed segments draw in two lanes (a 5 px dark edge under a 3 px
mode colour), one line per lane for each run of joined segments in one of 29
age bands, re-banded once per displayed second or style change; the live head
runs from its segment's start to the marker and is rewritten only when the
marker moves. Where the scene hides the trail, the colour lane draws at up to
0.55 alpha. Trails are not draped yet (the contract's ground corridor will
drape them), so history appears once its floors resolve. Trail geometry is bounded to 640 prepared vertices, prioritizing the active corridor and shortening the oldest geometry first. Active and future marker corridors retain their own 640-vertex budget; long report intervals use coarser subdivisions than 25 m instead of dropping an admitted vehicle. Deselecting releases selection geometry; marker paths retain only
shorten that history. Only the selected vehicle has a trail, drawn as
`engine.ground` corridors draped on terrain and 3D tiles on every engine:
completed segments draw in two lanes (a 5 px dark edge under a 3 px mode
colour), one corridor per lane for each run of joined segments in one of 29
age bands, re-banded once per displayed second or style change. The active
segment is one corridor with its dark backing; its geometry is written once
per segment and a shader reveal (`setReveal`) draws it up to the marker, so it
is not rewritten while the marker moves. Trail geometry is bounded to 640 prepared vertices, prioritizing the active corridor and shortening the oldest geometry first. Active and future marker corridors retain their own 640-vertex budget; long report intervals use coarser subdivisions than 25 m instead of dropping an admitted vehicle. Deselecting releases selection geometry; marker paths retain only
active and future corridors.
MBTA history is also retained in the running proxy: up to 15 minutes and 128
@@ -5738,7 +5760,7 @@ silently demoting every later lookup for the session.
When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration.
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
Close selected-pad views add one static 500 m-radius cyan launch-zone ring at the terrain height under the pad, drawn over the scene; the contract has no surface fill yet, so the translucent fill is gone. The ring is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
Close selected-pad views add one static 500 m-radius cyan launch zone, an `engine.ground` disc with the radial material (a faint fill, a bright soft rim and emission) draped on terrain and 3D tiles. The zone is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request.
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
@@ -6286,8 +6308,8 @@ 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. `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.
- **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; a file's icon looks share a budget of 16 million canvas pixels, so many distinct styles draw at the same size from lower-resolution textures. A KML heading turns with the camera's heading, staying measured from north. Ground and relative pins sample loaded terrain and, at camera heights below 250 km with a tile surface, `engine.surface.clampToHeight` for the pins in the window; absolute pins keep their height. The first sampling and every refresh run in batches of 256 that yield between them; refreshes go 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.** Raised polygons draw their fills (meshes) and outlines at their vertices' heights. Ground overlays take their colour's tint and alpha; a rotated one is turned on a canvas over the box around it, and an absolute one is raised to its altitude, as Cesium does (a relative one drapes, as on Cesium). Wall fills, extruded prisms, `gx:LatLonQuad`, a rotated overlay whose box passes a pole and a raised plain-colour overlay are counted as undrawn; wall top edges and polygon top geometry remain. Rhumb edges become great circles. Packed image overlays draw on both engines. A GeoJSON line of one point or a polygon of fewer than three distinct corners is not a record, so a file of only those is refused as having nothing to draw.
- **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 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.
@@ -6868,7 +6890,7 @@ easier to meet (detection is now on more often), but does not create it.
counters, and timing labels.
- Voice debug log: `tail -f .gev-logs/realtime-conversations.jsonl` (gitignored).
Replay chase-camera updates run in Cesium `preUpdate` before scene traversal, preventing 3D-tile refinement stutter when the mission replay speed is reduced. Replay Ascent first gives the selected launch site a five-second tile-preparation hold before displaying the T-minus countdown. Replay ascent duration is mission-specific: disclosed insertion, SECO, or separation timing is compressed into the replay, while sparse records use reconstructed path length with bounded fallback timing instead of a universal fixed duration. Replay transitions directly from ascent to orbit; stage re-entry/recovery remains static contextual linework and is never a camera-tracked playback phase. The screen-space rocket/thrust symbol renders at 50% of its 92 × 138 px design box, while the separate callout text remains unchanged. Successful/upcoming selected missions show a current orbit marker: green for a reliable TLE match or amber and explicitly estimated when no live match exists. Failed launches show their source status and suppress live/estimated orbit markers and fallback rings. A retained Launch Library orbit is labeled as the planned target, an absent ascent is reported as unavailable, and replay-only controls remain hidden unless the selected mission has a rendered track; authoritative supplied trajectory points remain visible when present. Mission orbit primitives realign by model matrix each tick from the same current-GMST frame as their marker, preventing ring/marker drift; their host annotation reads the position cache updated in that same tick. During depth-dominant ascent segments, the replay rocket retains its last valid path-facing rotation rather than snapping toward the camera. Mission selection and replay overlays never call photoreal `sampleHeight()` from the render loop; the launch-zone rim (a ring at the terrain height under the pad, drawn over the scene; the contract has no surface fill) and precomputed surface-safe replay path avoid remote tile-refinement probes that previously caused a one-second globe texture pulse. The shared host replaces the former mission-label visibility churn and quadratic overlap loop; the layer's remaining frame sweep only culls native point/billboard geometry and refreshes selected UTC copy when its displayed second changes. Replay samples uneven path vertices by cumulative distance and normalizes camera-yaw easing to frame time.
Replay chase-camera updates run in Cesium `preUpdate` before scene traversal, preventing 3D-tile refinement stutter when the mission replay speed is reduced. Replay Ascent first gives the selected launch site a five-second tile-preparation hold before displaying the T-minus countdown. Replay ascent duration is mission-specific: disclosed insertion, SECO, or separation timing is compressed into the replay, while sparse records use reconstructed path length with bounded fallback timing instead of a universal fixed duration. Replay transitions directly from ascent to orbit; stage re-entry/recovery remains static contextual linework and is never a camera-tracked playback phase. The screen-space rocket/thrust symbol renders at 50% of its 92 × 138 px design box, while the separate callout text remains unchanged. Successful/upcoming selected missions show a current orbit marker: green for a reliable TLE match or amber and explicitly estimated when no live match exists. Failed launches show their source status and suppress live/estimated orbit markers and fallback rings. A retained Launch Library orbit is labeled as the planned target, an absent ascent is reported as unavailable, and replay-only controls remain hidden unless the selected mission has a rendered track; authoritative supplied trajectory points remain visible when present. Mission orbit primitives realign by model matrix each tick from the same current-GMST frame as their marker, preventing ring/marker drift; their host annotation reads the position cache updated in that same tick. During depth-dominant ascent segments, the replay rocket retains its last valid path-facing rotation rather than snapping toward the camera. Mission selection and replay overlays never call photoreal `sampleHeight()` from the render loop; the launch zone (a radial ground disc draped on the scene) and precomputed surface-safe replay path avoid remote tile-refinement probes that previously caused a one-second globe texture pulse. The shared host replaces the former mission-label visibility churn and quadratic overlap loop; the layer's remaining frame sweep only culls native point/billboard geometry and refreshes selected UTC copy when its displayed second changes. Replay samples uneven path vertices by cumulative distance and normalizes camera-yaw easing to frame time.
Orbit replay framing uses one combined bounding sphere for Earth and every sample of the selected orbit. The camera derives its final range from that full envelope, while its look-at target retains a radial bias toward the moving vehicle rather than collapsing onto the singular Earth-center frame. Compact-orbit launchers therefore remain tracked during camera rotation, and highly eccentric transfer orbits still keep both the globe and their distant apogee arc visible. The orbital camera stays on one side of the mission's 3D orbit plane and uses the vehicle radial as visual up, so forward motion remains screen-left through polar/local-heading wraps instead of alternating left and right. The fixed-size cyan orbit dot retains one pixel scale throughout the pullback. While replay owns the camera, the selected launch-site host label is suppressed so it cannot duplicate or overlap the replay vehicle's DOM callout; cancel/completion restores it.
+40 -26
View File
@@ -36,7 +36,9 @@ so an expanding callout can fade its outline without changing its width.
Instances move every frame without a rebuild. `depthTest` decides whether
the scene hides them. A pick returns the instance's `pickId` when the set is
`pickable`.
`pickable`. `ready(index?)` says whether a model's glTF or a sprite atlas has
loaded and `failed(index?)` whether it failed to: a failed set is not loaded
again, so a layer that retries makes a new set.
## Lines: polylines in the air
@@ -87,8 +89,10 @@ Item options:
draws on top. All ground fills draw under movers, lines and models.
- `pickable: true` makes picks return an item's `pickId`, with `owner` (the
set) and `instance` (the item's index).
- `drape` is `'both'` (the default), `'terrain'` or `'tiles'`. Cesium
honours it; Navara drapes on whatever surface it draws.
- `drape` is `'both'` (the default), `'terrain'` or `'tiles'`. Both engines
honour it: Navara tells 3D tiles from terrain by its globe depth, and its
CPU picks skip a set draped only on the surface not shown (terrain while
photoreal tiles show, tiles otherwise).
Discs with a fill may use `radial: { fillAlpha, rimAlpha, rimStart, rimEnd,
emission }`. Alpha multipliers are in 0..1; the radius fractions satisfy
@@ -313,7 +317,8 @@ provide rectangle bounds, split, alpha, order, or drawing on 3D tiles.
- **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.
0.99 as on Cesium; a tinted or rotated image is drawn on a canvas first,
and an absolute one is raised to its altitude.
- **Sea Marks and Ski Pistes** (`src/data/osmOverlays.js`): tile layers at
order 10 on engines without `maps.addOverlay`.
@@ -324,21 +329,31 @@ provide rectangle bounds, split, alpha, order, or drawing on 3D tiles.
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.
raised paths and outlines on lines, raised polygon fills on meshes, 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.
subregion, colour, scale and rotation; a KML heading turns with the camera's
heading, so it stays measured from north. Icon textures share a per-file
budget of 16 million pixels: many distinct styles get lower-resolution
textures at the same size. Ground heights come from the loaded surface,
refreshed after the camera or map settles, in batches of 256; on 3D tiles
only pins in the window are clamped to the tiles drawn. 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.
Raised polygons (KML absolute or relative) draw their fills as meshes and
their outlines as lines at the vertices' heights. A ground overlay's colour
tints its image and its alpha fades it; a rotated overlay's image is turned
on a canvas over the box around it, as Cesium turns its rectangle in degrees,
and an absolute overlay is raised to its altitude (Cesium raises only
absolute ones). The shared display has known limits against Cesium: wall
fills and extruded prisms are omitted (their top geometry remains), and
`gx:LatLonQuad`, a rotated overlay whose box passes a pole and a raised
plain-colour overlay are omitted. These are counted as undrawn. Ground edges
use great circles rather than rhumb lines.
## Input ownership
@@ -574,10 +589,10 @@ data; displays do not receive native viewers or SDK types.
Implement the required host, camera, picker, projector, frame, render, surface,
input, credit, clock and map interfaces, plus all five drawing primitives:
`movers`, `lines`, `ground`, `imagery` and `meshes`. `engineCapabilities(engine)`
reports these and the optional visualStyles, bloom, cloudCover and contactSonar
groups. An optional group must be complete or absent. Do not advertise a
capability with a no-op implementation. Match Cesium's behavior where the
`movers`, `lines`, `ground`, `imagery` and `meshes`. `assertGlobeEngine(engine)`
checks these required interfaces. `engineCapabilities(engine)` reports only the
optional visualStyles, bloom, cloudCover and contactSonar groups. An optional
group must be complete or absent. Do not advertise a capability with a no-op implementation. Match Cesium's behavior where the
contract permits both engines to implement it.
Run `createContractSuite(engine, { waitFrames, ... })` from
@@ -594,14 +609,13 @@ folder and passing contract suite are the implementation boundary, not a claim
that arbitrary engine folders are auto-discovered today. Layers and their
fixtures should need no new drawing implementation.
The layer manifest (`src/app/layers/renderers.js`) loads shared displays and
lists their `needs`. Today every valid engine must implement all five
primitives; availability is checked against the registered kind's primitive
list. `backends` supplies optional engine-specific display options, currently
only wind's `flow`. Load a backend dynamically from its engine folder. A new
engine can omit wind streamlines and still draw the shared raster field. Do not
copy another engine's extensions into a new engine. The self-drawing map overlay
entries currently list supported kinds explicitly and need registration too.
The layer manifest (`src/app/layers/renderers.js`) loads shared factories through
`display`. Every valid engine implements all five primitives; the manifest has
no per-kind primitive lists or `needs` declarations. `backends` supplies optional
engine-specific display options, currently only wind's `flow`, loaded dynamically
from its engine folder. A missing wind backend leaves the shared raster field
available. Self-drawing layers and map overlays use the same contract and do not
register supported engine kinds in the manifest.
Run `src/app/layers/displayRehearsal.test.mjs`: it loads every manifest display
on the fake engine with an unknown kind and empty extensions. Its small fixtures
+7 -2
View File
@@ -12,6 +12,7 @@
import { existsSync, readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { parse } from '@babel/parser';
import { vueScripts } from './vue-scripts.mjs';
const root = new URL('..', import.meta.url);
@@ -24,7 +25,7 @@ export function sourceFiles() {
.split('\n')
.filter(
(path) =>
/\.(?:js|mjs)$/.test(path) &&
/\.(?:js|mjs|vue)$/.test(path) &&
!/\.test\.m?js$/.test(path) &&
!path.startsWith('src/testSupport/') &&
!path.includes('/testing/') &&
@@ -75,7 +76,7 @@ function isReference(node, parent, key) {
parent.type === 'ImportSpecifier' ||
parent.type === 'ImportNamespaceSpecifier' ||
parent.type === 'ImportDefaultSpecifier' ||
parent.type === 'ExportSpecifier'
(parent.type === 'ExportSpecifier' && key !== 'local')
)
return false;
if (parent.type === 'LabeledStatement' || parent.type === 'BreakStatement')
@@ -89,6 +90,10 @@ function isReference(node, parent, key) {
* members and heritage run at load and stay not deferred.
*/
export function loadTimeReferences(code, path = '<module>') {
if (path.endsWith('.vue'))
return vueScripts(code, path).flatMap((script) =>
loadTimeReferences(script).map((reference) => ({ ...reference, path })),
);
const program = parse(code, {
sourceType: 'module',
plugins: ['classProperties', 'classStaticBlock', 'importAttributes'],
+24
View File
@@ -40,3 +40,27 @@ test('no shipped module reads Cesium while it loads', () => {
);
assert.deepEqual(found, []);
});
test('re-exporting imported Cesium bindings reads them while loading', () => {
assert.deepEqual(
reads("import { Color } from 'cesium'; export { Color as Tint };"),
['1:Color'],
);
assert.deepEqual(reads("import * as C from 'cesium'; export { C };"), [
'1:C',
]);
});
test('Vue scripts participate in the load-time check with original line numbers', () => {
assert.deepEqual(
loadTimeReferences(
`<template><span>Cesium</span></template>
<script>
import { Color } from 'cesium';
export const tint = Color.RED;
</script>`,
'src/View.vue',
).map(({ line, name }) => `${line}:${name}`),
['4:Color'],
);
});
+9 -26
View File
@@ -8,7 +8,7 @@ import { analyzeModule } from './module-analysis.mjs';
const builtins = new Set(
builtinModules.flatMap((name) => [name, `node:${name}`]),
);
const code = /\.[mc]?js$/;
const code = /\.(?:[mc]?js|vue)$/;
const entry = (file) =>
file === 'src/main.js' || file.startsWith('src/standalone/');
const tests = (file) =>
@@ -30,12 +30,6 @@ const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file);
const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/;
// Navara renders through three.js and its postprocessing peer.
const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
// The modules that load Navara renderers, each through import().
const NAVARA_RENDERER_LOADERS = new Set([
'src/app/navaraMode.js',
'src/app/layers/renderers.js',
]);
const navaraLayerRenderer = (file) => /\/render\/navara[^/]*\.js$/.test(file);
const renderer = (file) =>
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
@@ -125,6 +119,7 @@ export function checkImportDirections(root) {
try {
analysis = analyzeModule(readFileSync(resolved, 'utf8'), {
dynamic: true,
filename: file,
});
} catch (error) {
report(file, error.message);
@@ -188,27 +183,12 @@ export function checkImportDirections(root) {
if (
to.startsWith('src/globe/navara/') &&
!file.startsWith('src/globe/navara/') &&
!analysis.dynamicImports.includes(specifier)
analysis.staticImports.includes(specifier)
)
report(
file,
`The Navara engine loads only through a dynamic import(): ${to}`,
);
// Like the engine, a layer's Navara renderer reaches the app only
// through an import() of the Navara mode (src/app/navaraMode.js) or
// of the layer manifest (src/app/layers/renderers.js).
if (
navaraLayerRenderer(to) &&
!navaraLayerRenderer(file) &&
!(
NAVARA_RENDERER_LOADERS.has(file) &&
analysis.dynamicImports.includes(specifier)
)
)
report(
file,
`A Navara layer renderer loads only through the Navara mode's dynamic import(): ${to}`,
);
if (file.startsWith('src/') && to.startsWith('server/'))
report(file, `Browser imports server: ${to}`);
if (
@@ -322,8 +302,8 @@ export function checkImportDirections(root) {
}
for (const file of files.filter(globeContract)) globe(file);
// Each engine graph stays on its own engine, through helpers too.
for (const start of files.filter(globeEngine)) {
const navara = start.startsWith('src/globe/navara/');
for (const owner of ['cesium', 'navara']) {
const navara = owner === 'navara';
const label = navara ? 'Navara globe engine' : 'Cesium globe engine';
const foreignPackage = navara ? cesiumPackage : navaraPackage;
const foreignEngine = navara ? 'src/globe/cesium/' : 'src/globe/navara/';
@@ -342,7 +322,10 @@ export function checkImportDirections(root) {
else visit(to);
}
};
visit(start);
for (const start of files.filter((file) =>
file.startsWith(`src/globe/${owner}/`),
))
visit(start);
}
// Common voice controls may use their DOM view, but never a protocol implementation.
const voiceSeen = new Set();
+9 -3
View File
@@ -1,4 +1,5 @@
import { parsers } from 'prettier/plugins/babel.mjs';
import { vueScripts } from './vue-scripts.mjs';
const browserGlobals = new Set([
'window',
@@ -19,9 +20,10 @@ const browserGlobals = new Set([
* Inspect literal module dependencies and browser-platform references without
* executing code. With `dynamic`, also list the import() specifiers apart.
*/
export function analyzeModule(source, { dynamic = false } = {}) {
export function analyzeModule(source, { dynamic = false, filename = '' } = {}) {
const imports = [];
const dynamicImports = [];
const staticImports = [];
const browser = new Set();
function walk(node, parent, field) {
if (!node || typeof node !== 'object') return;
@@ -41,6 +43,7 @@ export function analyzeModule(source, { dynamic = false } = {}) {
imports.push(node.source.value);
if (node.type === 'ImportExpression')
dynamicImports.push(node.source.value);
else staticImports.push(node.source.value);
}
if (node.type === 'CallExpression' && node.callee?.name === 'require')
throw new Error('Runtime modules must use ES imports');
@@ -76,11 +79,14 @@ export function analyzeModule(source, { dynamic = false } = {}) {
else if (value && typeof value === 'object') walk(value, node, key);
}
}
walk(parsers.babel.parse(source));
for (const script of filename.endsWith('.vue')
? vueScripts(source, filename)
: [source])
walk(parsers.babel.parse(script));
return {
imports,
browser: [...browser],
...(dynamic && { dynamicImports }),
...(dynamic && { dynamicImports, staticImports }),
};
}
+4
View File
@@ -2259,6 +2259,7 @@
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/layers/submarineCables/render/display.js",
"src/layers/shared/render/surfacePoints.js",
"src/overlays/bindOverlayHost.js"
],
"external": ["cesium"]
@@ -3595,6 +3596,7 @@
"src/layers/earthquakes/render/display.js",
"src/layers/airQuality/render/display.js",
"src/layers/submarineCables/render/display.js",
"src/layers/shared/render/surfacePoints.js",
"src/layers/firms/render/sprites.js",
"src/layers/cyclones/render/display.js",
"src/layers/droneAirspace/render/display.js",
@@ -4352,6 +4354,7 @@
"src/layers/gnss/render/display.js",
"src/layers/firms/render/sprites.js",
"src/layers/submarineCables/render/display.js",
"src/layers/shared/render/surfacePoints.js",
"src/layers/perimeters/render/display.js",
"src/layers/droneAirspace/render/display.js",
"src/globe/ground.js",
@@ -4539,6 +4542,7 @@
"ui-share-restoration": {
"exports": ["./ui/share-restoration"],
"modules": [
"src/ui/layerNames.js",
"src/i18n/marker.js",
"src/data/installationFeedback.js",
"src/data/idSwitches.js",
+3 -2
View File
@@ -111,7 +111,7 @@ try {
// paths in one engine.ground corridor set, landing points and reference
// tips in mover sets, stems in engine.lines, labels on the world-overlay
// host. It owns no Cesium DataSource or Entity any more.
const counts = await page.evaluate((layerId) => {
const counts = await page.evaluate((layerId, legacy) => {
const gev = window.__godsEyeView;
const v = gev.viewer;
const engine = gev.engine;
@@ -126,6 +126,7 @@ try {
out.layerDataSources++;
out.layerEntities += ds.entities.values.length;
}
if (legacy) return out;
for (const set of engine.ground.list()) {
if (set.id !== `${layerId}-paths`) continue;
out.pathSets++;
@@ -146,7 +147,7 @@ try {
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
out.referenceLabelCount = stats.referenceLabelCount ?? null;
return out;
}, LAYER_ID);
}, LAYER_ID, legacy);
report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts);
// ── a. per-frame scene.render cost over a ~10 s driven orbit ─────────
+4 -3
View File
@@ -1485,8 +1485,9 @@ async function main() {
const scene = viewer.scene;
const canvas = scene.canvas;
const rect = canvas.getBoundingClientRect();
const { hitTestWorldOverlay } =
(await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine);
const { hitTest } = (
await import('/src/overlays/worldOverlay.js')
).worldOverlayFor(window.__godsEyeView.engine);
const candidates = [
[0.55, 0.72],
[0.45, 0.72],
@@ -1508,7 +1509,7 @@ async function main() {
const x = Math.round(canvas.clientWidth * fx);
const y = Math.round(canvas.clientHeight * fy);
const picked = scene.pick({ x, y });
const card = hitTestWorldOverlay(x, y, { sourceId: 'cctv' });
const card = hitTest(x, y, { sourceId: 'cctv' });
const top = document.elementFromPoint(rect.left + x, rect.top + y);
if (
resolveId(picked) === null &&
+1 -2
View File
@@ -36,8 +36,7 @@ const check = (name, passed, detail = '') => {
const browser = await puppeteer.launch({
headless: process.env.QA_HEADFUL === '1' ? false : true,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
+3 -1
View File
@@ -65,7 +65,9 @@ await strict.run({}, async (browser) => {
(kind) =>
window.__godsEyeView?.engine?.kind === kind &&
document.querySelector('#globe-engine')?.value === kind &&
!document.querySelector('#globe-engine').disabled &&
!document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
) &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90000 },
kind,
+18 -31
View File
@@ -2,7 +2,12 @@ import { assertKeylessPreview } from './qa-strict-harness.mjs';
import assert from 'node:assert/strict';
import { installAlprFixture, cameras } from './fixtures/layers/alpr.mjs';
import { firmsFixture, FIRMS_VALID_COUNT } from './fixtures/layers/firms.mjs';
import { createStrict, localPreview } from './qa-strict-harness.mjs';
import {
clickEmptyGround,
createStrict,
localPreview,
waitForCameraAtRest,
} from './qa-strict-harness.mjs';
const url = localPreview(process.argv[2]),
fire = process.argv.includes('--fires'),
id = fire ? 'local-firms' : 'alpr-cameras';
@@ -113,36 +118,14 @@ await strict.run({}, async (b) => {
);
// A click on empty ground (nothing picked there, nothing covering it).
const clickEmpty = async () => {
const handle = await p.waitForFunction(
() => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const box = canvas.getBoundingClientRect();
for (let y = 240; y < 600; y += 9)
for (let x = 440; x < 840; x += 9) {
if (
!canvas.contains(
document.elementFromPoint(box.left + x, box.top + y),
)
)
continue;
let free = true;
for (const [dx, dy] of [
[0, 0],
[6, 0],
[-6, 0],
[0, 6],
[0, -6],
])
if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false;
if (free) return { x: box.left + x, y: box.top + y };
}
return false;
},
{ timeout: 15000 },
);
const at = await handle.jsonValue();
await p.mouse.click(at.x, at.y);
await clickEmptyGround(p, {
left: 440,
top: 240,
right: 840,
bottom: 600,
step: 9,
radius: 6,
});
await p.waitForFunction(
(fire) =>
fire
@@ -165,6 +148,10 @@ await strict.run({}, async (b) => {
xy,
);
await new Promise((r) => setTimeout(r, 1600));
// The selection flies the camera to the record (1.9 s, longer on a busy
// machine); a switch before it lands opens the other engine on a view
// between the two, where the record can be off screen.
await waitForCameraAtRest(p);
const expectedId = fire
? await p.evaluate(() => window.qaGround.getSelectedInfo().id)
: xy.id;
+60 -51
View File
@@ -9,6 +9,7 @@ import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import sharp from 'sharp';
import { fromDegrees } from '../src/geo/cartesian3.js';
import { watchBrowserPage, redact } from './qa-browserEvidence.mjs';
const urlIndex = process.argv.indexOf('--url');
@@ -150,23 +151,25 @@ async function open(mode = 'normal') {
'0'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const { dataManager, engine } = window.__godsEyeView;
await window.__godsEyeView.styleManager.initialRestorePromise;
window.__godsEyeView.styleManager.orbitController.stop();
for (const [id, layer] of dataManager.layers) {
if (layer.enabled)
await dataManager.setEnabled(id, false, { origin: 'user' });
}
const geo = await import('/src/geo/index.js');
engine.camera.cancelFlight();
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
window.__godsEyeView.requestRender('gbfs-name-qa');
});
await page.evaluate(
async (destination) => {
const { dataManager, engine } = window.__godsEyeView;
await window.__godsEyeView.styleManager.initialRestorePromise;
window.__godsEyeView.styleManager.orbitController.stop();
for (const [id, layer] of dataManager.layers) {
if (layer.enabled)
await dataManager.setEnabled(id, false, { origin: 'user' });
}
engine.camera.cancelFlight();
engine.camera.setView({
destination,
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
window.__godsEyeView.requestRender('gbfs-name-qa');
},
fromDegrees(-97.7449, 30.2701, 3000),
);
return { page, errors };
}
@@ -220,15 +223,17 @@ async function w2Checks(page) {
['close', 3000],
['global', 100000],
]) {
await page.evaluate(async (height) => {
const app = window.__godsEyeView;
await app.dataManager.setEnabled('bikeshare', false);
const geo = await import('/src/geo/index.js');
app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, height),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}, height);
await page.evaluate(
async (destination) => {
const app = window.__godsEyeView;
await app.dataManager.setEnabled('bikeshare', false);
app.engine.camera.setView({
destination,
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
},
fromDegrees(-97.7449, 30.2701, height),
);
await settledTiles(page);
await nextFrame(page);
const off = await page.screenshot({
@@ -304,15 +309,17 @@ async function w2Checks(page) {
for (const element of document.querySelectorAll('.gbfs-raster-hidden'))
element.classList.remove('gbfs-raster-hidden');
});
await page.evaluate(async () => {
const app = window.__godsEyeView;
const geo = await import('/src/geo/index.js');
app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await app.dataManager.setEnabled('bikeshare', false);
});
await page.evaluate(
async (destination) => {
const app = window.__godsEyeView;
app.engine.camera.setView({
destination,
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await app.dataManager.setEnabled('bikeshare', false);
},
fromDegrees(-97.7449, 30.2701, 3000),
);
for (let i = 0; i < 10; i++) {
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
@@ -520,22 +527,24 @@ try {
);
}
await page.setViewport({ width: 390, height: 844 });
await page.evaluate(async () => {
window.__qaPainted = [];
const app = window.__godsEyeView;
// Use the supported clean view to inspect the mobile card clear of panels.
app.styleManager.toggleCleanView(true);
const geo = await import('/src/geo/index.js');
app.engine.camera.cancelFlight();
app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
app.dataManager.layers
.get('bikeshare')
.module._selectBikeshareStationForTest('austin-capmetro:english');
app.requestRender('gbfs-name-mobile-qa');
});
await page.evaluate(
async (destination) => {
window.__qaPainted = [];
const app = window.__godsEyeView;
// Use the supported clean view to inspect the mobile card clear of panels.
app.styleManager.toggleCleanView(true);
app.engine.camera.cancelFlight();
app.engine.camera.setView({
destination,
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
app.dataManager.layers
.get('bikeshare')
.module._selectBikeshareStationForTest('austin-capmetro:english');
app.requestRender('gbfs-name-mobile-qa');
},
fromDegrees(-97.7449, 30.2701, 3000),
);
await page.waitForFunction(
() => window.__qaPainted.includes('QA English Square'),
{ timeout: 10000 },
+15 -31
View File
@@ -2,9 +2,11 @@ import { fromDegrees } from '../src/geo/cartesian3.js';
import sharp from 'sharp';
import assert from 'node:assert/strict';
import {
clickEmptyGround,
createStrict,
localPreview,
openPage,
waitForCameraAtRest,
waitForEngine,
} from './qa-strict-harness.mjs';
@@ -400,39 +402,19 @@ await strict.run({}, async (b) => {
// Click-picking on the running engine: clear the selection with a click on
// empty water, then select the vessel with a click on its chevron.
const pickVessel = async () => {
// The last selection flies the camera to the vessel, and a click does
// not cut that flight short: click once it has landed.
await waitForCameraAtRest(p);
// Empty ground: a point in the middle of the view where the engine
// picks nothing and nothing covers the canvas.
const empty = await p
.waitForFunction(
() => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const box = canvas.getBoundingClientRect();
for (let y = 220; y < 560; y += 11)
for (let x = 300; x < 720; x += 11) {
if (
!canvas.contains(
document.elementFromPoint(box.left + x, box.top + y),
)
)
continue;
let free = true;
for (const [dx, dy] of [
[0, 0],
[10, 0],
[-10, 0],
[0, 10],
[0, -10],
])
if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false;
if (free) return { x: box.left + x, y: box.top + y };
}
return false;
},
{ timeout: 15000 },
)
.then((handle) => handle.jsonValue());
await p.mouse.click(empty.x, empty.y);
await clickEmptyGround(p, {
left: 300,
top: 220,
right: 720,
bottom: 560,
step: 11,
radius: 10,
});
await p.waitForFunction(
() => !window.qaVesselEntry.module.getSelectedInfo(),
);
@@ -447,6 +429,8 @@ await strict.run({}, async (b) => {
'ais-live-vessels',
'a click on the chevron selects the vessel',
);
// The next switch starts from the landed view.
await waitForCameraAtRest(p);
};
await click(-79.38, 43.65);
await p.waitForFunction(
+11 -13
View File
@@ -10,8 +10,9 @@ export async function checkInstallationPan(page, dir) {
);
await sleep(3000);
await page.evaluate(async () => {
const { getOverlayPaintRect } =
(await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine);
const { getPaintRect } = (
await import('/src/overlays/worldOverlay.js')
).worldOverlayFor(window.__godsEyeView.engine);
const g = window.__godsEyeView;
const installations = g.dataManager.layers.get(
'military-installations',
@@ -28,10 +29,10 @@ export async function checkInstallationPan(page, dir) {
const sites = [],
centers = [];
installations.visitNamedMarkers((id) => {
if (getOverlayPaintRect('military-installations', id)) sites.push(id);
if (getPaintRect('military-installations', id)) sites.push(id);
});
for (const id of centerIds)
if (getOverlayPaintRect('local-datacenters', id)) centers.push(id);
if (getPaintRect('local-datacenters', id)) centers.push(id);
window.__polishPanFrames.push({ t: performance.now(), sites, centers });
});
});
@@ -187,8 +188,9 @@ export async function checkInstallationSelection(
window.__polishSelectionFrames = [];
const { getSelectedEntityContext } =
await import('/src/data/contextStore.js');
const { getOverlayPaintRect, getWorldOverlayDiagnostics } =
(await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine);
const { getPaintRect, getDiagnostics } = (
await import('/src/overlays/worldOverlay.js')
).worldOverlayFor(window.__godsEyeView.engine);
// The selected site's fill is one engine.ground polygon set
// (render/display.js SELECTION_FILL_SET_ID), an item per footprint
// part; the layer owns no Cesium entities or ground primitives.
@@ -225,17 +227,13 @@ export async function checkInstallationSelection(
g.viewer.scene.postRender.addEventListener(() => {
window.__polishSelectionFrames.push({
paints: window.__polishTitlePaints.splice(0).length,
own: Boolean(
getOverlayPaintRect('military-installations', record.id),
),
own: Boolean(getPaintRect('military-installations', record.id)),
tracked: Boolean(
getOverlayPaintRect('tracked', `installations:${record.id}`),
getPaintRect('tracked', `installations:${record.id}`),
),
selected: getSelectedEntityContext()?.id === record.id,
total:
getWorldOverlayDiagnostics().paintedBySource[
'military-installations'
] || 0,
getDiagnostics().paintedBySource['military-installations'] || 0,
});
});
return {
+2 -2
View File
@@ -7,8 +7,8 @@
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after [--engine=cesium|navara]
*
* Engine-neutral: camera, tiles and layers go through the globe engine and the
* QA probe; an engine without the flights/satellites layers (Navara today)
* shoots the scenes without a contact field and says so.
* QA probe; an engine without the flights/satellites layers shoots the
* scenes without a contact field and says so.
*
* Every scene pins its own camera pose AND substitutes a deterministic
* synthetic contact field for the live pollers, so a `before` run and an
+2 -2
View File
@@ -7,8 +7,8 @@
*
* Engine-neutral: the camera and frames go through the globe engine and the
* layers through the QA probe (window.__godsEyeView.layers). On an engine
* without the flights, military and satellites layers (Navara today) the
* field checks are SKIPPED and the settings checks still run.
* without the flights, military and satellites layers the field checks are
* SKIPPED and the settings checks still run.
*
* The harness replaces only the three detection read APIs in the loaded page.
* It does not enable their network pollers or mutate repository data. The field
+3 -1
View File
@@ -111,7 +111,9 @@ await strict.run({}, async (browser) => {
page.waitForFunction(
(engine) =>
window.__godsEyeView?.engine?.kind === engine &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
) &&
document.querySelector('#loading-screen.hidden') &&
!document.querySelector('.engine-switch-frame'),
{ timeout: 90000 },
+13 -13
View File
@@ -152,14 +152,15 @@ try {
await settle();
const before = await stats();
const overlaps = await page.evaluate(async () => {
const { getOverlayPaintRect } =
(await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine);
const { getPaintRect } = (
await import('/src/overlays/worldOverlay.js')
).worldOverlayFor(window.__godsEyeView.engine);
const layer = window.__godsEyeView.dataManager.layers.get(
'military-installations',
).module;
const rects = [];
layer.visitNamedMarkers((id) => {
const rect = getOverlayPaintRect('military-installations', id);
const rect = getPaintRect('military-installations', id);
if (rect) rects.push(rect);
});
let overlaps = 0;
@@ -223,9 +224,7 @@ try {
opacityProperty: true,
}),
labels:
host.getWorldOverlayDiagnostics().paintedBySource[
'military-installations'
] || 0,
host.getDiagnostics().paintedBySource['military-installations'] || 0,
};
});
assert.ok(
@@ -244,10 +243,10 @@ try {
),
);
result.disabled = await page.evaluate(async () => {
const host = (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine);
return host.getWorldOverlayDiagnostics().entriesBySource[
'military-installations'
];
const host = (
await import('/src/overlays/worldOverlay.js')
).worldOverlayFor(window.__godsEyeView.engine);
return host.getDiagnostics().entriesBySource['military-installations'];
});
assert.equal(result.disabled, 0);
await page.evaluate(() =>
@@ -266,8 +265,9 @@ try {
}
await page.evaluate(async () => {
const C = await import('/node_modules/cesium/Build/Cesium/index.js');
const { getOverlayPaintRect } =
(await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine);
const { getPaintRect } = (
await import('/src/overlays/worldOverlay.js')
).worldOverlayFor(window.__godsEyeView.engine);
const g = window.__godsEyeView;
const layer = g.dataManager.layers.get('military-installations').module;
const overlay = document.createElement('div');
@@ -284,7 +284,7 @@ try {
labels = [],
rects = [];
layer.visitNamedMarkers((id, point) => {
const rect = getOverlayPaintRect('military-installations', id);
const rect = getPaintRect('military-installations', id);
if (rect) rects.push(rect);
const p = C.SceneTransforms.worldToWindowCoordinates(
g.viewer.scene,
+3 -1
View File
@@ -48,7 +48,9 @@ await strict.run(
(k) =>
window.__godsEyeView?.engine?.kind === k &&
window.__gevDrawTool?.attached &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
) &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout: 120000 },
+6 -4
View File
@@ -2,8 +2,8 @@
// UTILITIES ▸ Import on Navara under the strict network policy: GeoJSON,
// KML, KMZ (a packed icon and a ground overlay) and GPX through the real
// file input, each one row; Navara draws their shapes in the row colour and
// their pins, counts what it cannot draw (the KMZ ground overlay) and says
// so in the hint; hide, fly-to and remove work on the engine; the files
// their pins and packed ground overlay; unsupported content is counted
// in the hint; hide, fly-to and remove work on the engine; the files
// survive a switch to Cesium and back and draw on each engine. No file makes
// the page request anything it names (the strict policy fails any request
// outside the preview and the free tile cache).
@@ -152,7 +152,9 @@ await strict.run(
(k) =>
window.__godsEyeView?.engine?.kind === k &&
window.__gevGeoImport?.diagnostics().attached &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
) &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout: 120000 },
@@ -171,7 +173,7 @@ await strict.run(
rows: document.querySelectorAll('#geo-import-list .geo-import-row')
.length,
hint: document.getElementById('geo-import-hint')?.textContent || '',
// Navara draws the pins as mover sets; Cesium as entities (none).
// Both engines draw imported pins through mover sets.
pins: window.__godsEyeView.engine.movers
.list()
.filter(({ id }) => id.startsWith('geo-import-'))
+9 -9
View File
@@ -18,7 +18,7 @@
* node scripts/qa-overlay-baseline.mjs --engine=navara
*
* Engine-neutral: the camera and frames go through the globe engine and the
* layers through the QA probe (window.__godsEyeView.layers, dev server only).
* layers through the public engine and data manager surfaces.
* A scene whose layers the engine does not have is SKIPPED. The inventory
* counts the globe contract's sets (movers, lines, ground, imagery) on both
* engines; on Cesium it also counts what is left of Entities, LabelGraphics
@@ -29,6 +29,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { fromDegrees } from '../src/geo/cartesian3.js';
const CHROME_EXECUTABLE = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const DEFAULT_URL = 'http://localhost:4176';
@@ -362,7 +363,7 @@ async function installPageInstrumentation(page) {
async function waitForApp(page) {
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(
() => Boolean(window.__godsEyeView?.layers && window.__godsEyeView?.dataManager),
() => Boolean(window.__godsEyeView?.engine && window.__godsEyeView?.dataManager),
{ timeout: 60_000 },
);
await page.evaluate(() => {
@@ -375,17 +376,16 @@ async function waitForApp(page) {
async function setCamera(page, camera) {
if (!camera) return;
await page.evaluate(async ([lon, lat, height, heading, pitch]) => {
await page.evaluate(([destination, heading, pitch]) => {
const { engine, viewer } = window.__godsEyeView;
const geo = await import('/src/geo/index.js');
engine.camera.cancelFlight();
viewer?.scene.tweens?.removeAll?.();
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(lon, lat, height),
destination,
orientation: { heading, pitch, roll: 0 },
});
engine.render.request('qa-overlay-baseline');
}, camera);
}, [fromDegrees(...camera.slice(0, 3)), ...camera.slice(3)]);
}
async function readLayerState(page, layerId) {
@@ -646,7 +646,7 @@ async function prepareTrackedFlight(page, cockpit) {
});
if (!tracked.ok) return `Civil tracking unavailable (${tracked.reason})`;
try {
await page.waitForFunction(() => Boolean(window.__godsEyeView.layers.following()), { timeout: 10_000 });
await page.waitForFunction(() => Boolean(window.__godsEyeView.engine.camera.following()), { timeout: 10_000 });
} catch {
return 'Civil tracking did not create a tracked entity';
}
@@ -679,7 +679,7 @@ async function prepareMission(page) {
});
if (!selected) return 'Mission roster did not expose a selectable mission';
try {
await page.waitForFunction(() => Boolean(window.__godsEyeView.layers.drawn('rocket-launches')?.selected), { timeout: 10_000 });
await page.waitForFunction(() => Boolean(window.__godsEyeView.dataManager.layers.get('rocket-launches')?.module?.qaDrawn?.()?.selected), { timeout: 10_000 });
} catch {
return 'Mission selection did not reach the layer probe';
}
@@ -689,7 +689,7 @@ async function prepareMission(page) {
async function prepareScene(page, scene) {
const missing = await page.evaluate(
(ids) => ids.filter((id) => !window.__godsEyeView.layers.available(id)),
(ids) => ids.filter((id) => !window.__godsEyeView.dataManager.layers.has(id)),
scene.layers,
);
if (missing.length)
+6 -4
View File
@@ -71,7 +71,7 @@
* Usage: node scripts/qa-perf.mjs [--url http://localhost:4173] [--engine=cesium|navara]
* Engine-neutral: the camera, frames and layers go through the globe engine
* and the QA probe (window.__godsEyeView.layers); on an engine without a
* layer (Navara today) that layer's checks are SKIPPED, not failed.
* layer that layer's checks are SKIPPED, not failed.
* Requires a running dev server. Headless; flags disable occlusion
* throttling so rAF cadence is trustworthy (hidden-pane gotcha).
*/
@@ -338,14 +338,16 @@ try {
await page.evaluate(() => { window.__godsEyeView.styleManager.setStyle('retro'); });
await new Promise((r) => setTimeout(r, 900)); // crossfade + first ticks
const dAnim = await diag();
// The animated styles are Cesium post-process stages; Navara draws its own
// looks and has no style animation (a known gap, not a regression).
// This assertion checks the Cesium style-anim render hold.
if (engineKind === 'cesium') {
check('animated style takes the style-anim hold (continuous)', dAnim?.mode === 'continuous' && dAnim.holds.includes('style-anim'), dAnim);
const animFrames = await countFrames(2_000);
check('animated style keeps frames flowing (≥50% rAF)', animFrames.renders >= animFrames.rafs * 0.5, animFrames);
} else {
skip('animated style takes the style-anim hold and keeps frames flowing', `the visual styles are Cesium post stages; ${engineKind} has none`);
skip(
'animated style takes the style-anim hold and keeps frames flowing',
`this check measures the Cesium style-anim hold, not ${engineKind} animation`,
);
}
// Returning to normal keeps detection ON by design (it is the default, and
// detection persists across style switches). Since 2026-08-22 detection holds
+3 -1
View File
@@ -67,7 +67,9 @@ await strict.run({}, async (b) => {
await p.waitForFunction(
() =>
(window.__godsEyeView &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
) &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden')) ||
document.querySelector('.engine-switch-retry'),
+6 -2
View File
@@ -229,7 +229,9 @@ await strict.run({}, async (b) => {
window.__qaSlow.at &&
!window.__qaSlow.coverGone &&
!document.querySelector('.engine-switch-frame') &&
!document.querySelector('#globe-engine')?.disabled
!document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
)
) {
window.__qaSlow.coverGone = performance.now();
observer.disconnect();
@@ -245,7 +247,9 @@ await strict.run({}, async (b) => {
await p.waitForFunction(
(target) =>
window.__godsEyeView?.engine?.kind === target &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
) &&
!document.querySelector('.engine-switch-frame'),
{ polling: 200, timeout: 60000 },
target,
+80 -9
View File
@@ -440,10 +440,11 @@ export async function pickEngine(
?.getAttribute('aria-expanded') === 'true',
);
}
await page.waitForSelector('#globe-engine:not([disabled])', {
visible: true,
timeout: 90000,
});
// The picker stays focusable while busy: aria-busy marks a switch.
await page.waitForSelector(
'#globe-engine:not([disabled]):not([aria-busy="true"])',
{ visible: true, timeout: 90000 },
);
const from = await page.$eval('#globe-engine', (el) => el.value);
assert.notEqual(from, kind, `the picker already shows ${kind}`);
// The key typed below is `kind`'s first letter: it selects the next option
@@ -479,14 +480,16 @@ export async function pickEngine(
);
window.__qaPickObserver?.disconnect();
window.__qaPickObserver = new MutationObserver(() => {
const disabled = document.querySelector('#globe-engine')?.disabled;
const disabled = document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
);
if (disabled) window.__qaPick.busy = true;
else if (window.__qaPick.busy && window.__qaPick.idleAt == null)
window.__qaPick.idleAt = performance.now();
});
window.__qaPickObserver.observe(document.body, {
attributes: true,
attributeFilter: ['disabled'],
attributeFilter: ['disabled', 'aria-busy'],
subtree: true,
});
const describe = (hit) =>
@@ -523,8 +526,10 @@ export async function pickEngine(
// Whatever the person does next, right after the key (no round trip to
// the page in between).
if (afterInput) await afterInput();
// The change starts a cold switch at once: Cesium.js then runs as one
// task, seconds long under CPU throttling, before the page answers.
await page.waitForFunction(() => window.__qaPick?.changed, {
timeout: 5000,
timeout: 30000,
polling: 50,
});
// Headless Chrome opens no menu for a click on the select: the key
@@ -538,7 +543,9 @@ export async function pickEngine(
await page.waitForFunction(
() =>
(window.__qaPick.busy &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
) &&
document.querySelector('#loading-screen.hidden') &&
!document.querySelector('.engine-switch-frame')) ||
document.querySelector('.engine-switch-retry'),
@@ -572,7 +579,9 @@ export async function waitForEngine(page, kind, { timeout = 120000 } = {}) {
window.__godsEyeView?.engine?.kind ===
(expected === 'cesium' ? 'cesium' : 'navara') &&
document.querySelector('#globe-engine')?.value === expected &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
) &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout },
@@ -837,3 +846,65 @@ export function createStrict({
};
return strict;
}
/** Click uncovered canvas where the engine picks nothing within the radius. */
export async function clickEmptyGround(
page,
{ left, top, right, bottom, step = 9, radius = 6 },
) {
const handle = await page.waitForFunction(
({ left, top, right, bottom, step, radius }) => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const box = canvas.getBoundingClientRect();
for (let y = top; y < bottom; y += step)
for (let x = left; x < right; x += step) {
if (
!canvas.contains(
document.elementFromPoint(box.left + x, box.top + y),
)
)
continue;
const free = [
[0, 0],
[radius, 0],
[-radius, 0],
[0, radius],
[0, -radius],
].every(([dx, dy]) => !engine.picker.pick({ x: x + dx, y: y + dy }));
if (free) return { x: box.left + x, y: box.top + y };
}
return false;
},
{ timeout: 15000 },
{ left, top, right, bottom, step, radius },
);
try {
const at = await handle.jsonValue();
await page.mouse.click(at.x, at.y);
} finally {
await handle.dispose();
}
}
/**
* Wait until no flight holds the camera and it stays still. A selection's
* camera transfer is a flight; on both engines a flight turns the camera
* inputs off, and a click does not cut it short.
*/
export async function waitForCameraAtRest(page, { timeout = 15000 } = {}) {
await page.evaluate(() => {
window.__qaRestPose = null;
});
await page.waitForFunction(
() => {
const { engine } = window.__godsEyeView;
const { x, y, z } = engine.camera.pose().position;
const pose = [x, y, z].map((value) => value.toFixed(2)).join();
const still = window.__qaRestPose === pose;
window.__qaRestPose = pose;
return still && engine.input.cameraInputsEnabled();
},
{ timeout, polling: 100 },
);
}
+8 -2
View File
@@ -52,7 +52,11 @@ await strict.run({}, async (b) => {
window.__qaHidden.shown.push({
state: document.visibilityState,
at: performance.now(),
busy: !!document.querySelector('#globe-engine')?.disabled,
busy: Boolean(
document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
),
),
}),
);
}, kind);
@@ -69,7 +73,9 @@ await strict.run({}, async (b) => {
await p.waitForFunction(
(kind) =>
window.__godsEyeView?.engine?.kind === kind &&
!document.querySelector('#globe-engine')?.disabled &&
!document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
) &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ polling: 250, timeout: 60000 },
+9 -7
View File
@@ -1,4 +1,4 @@
// Contract: a parked globe is released after ?parkedMs= (3 s here; three
// Contract: a parked globe is released after ?parkedMs= (8 s here; three
// minutes by default). A switch back in time is warm (the same engine
// object); after the delay the parked engine's WebGL context is lost and a
// switch back builds a new engine on the same session that draws a
@@ -21,7 +21,8 @@ import {
startSession,
} from './qa-strict-session.mjs';
const PARKED_MS = 3000;
// Room for a cold switch on a busy machine (2-3 s) before the switch back.
const PARKED_MS = 8000;
const base = localPreview(process.argv[2]);
const other = (kind) => (kind === 'navara' ? 'cesium' : 'navara');
const strict = createStrict({ origin: base.origin, layers: [] });
@@ -41,8 +42,9 @@ await strict.run({}, async (b) => {
console.log('PASS', name);
};
const pick = async (kind) => {
await strict.pick(p, kind);
const { at } = await strict.pick(p, kind);
await waitForEngine(p, kind);
return at;
};
// The running engine is the one remembered under its kind (warm), and
// the context of `kind`'s remembered engine is lost or not.
@@ -77,17 +79,17 @@ await strict.run({}, async (b) => {
await pick(parked);
await rememberEngine(p);
}
await pick(running);
const parkedAt = await pick(running);
await rememberEngine(p);
// A switch back in time is warm.
await wait(PARKED_MS / 3);
await pick(parked);
await wait(1000);
const backAt = await pick(parked);
const early = await engines(parked);
await assertSession(p, parked, `a quick switch back to ${parked}`);
contract(
`a switch back to ${parked} within the delay reuses the parked engine`,
early.warm && early.lost === false,
early,
{ ...early, parkedMs: Math.round(backAt - parkedAt) },
);
// Parked past the delay: released, and the next switch back is cold.
await pick(running);
+5 -1
View File
@@ -89,7 +89,11 @@ await strict.run({}, async (b) => {
window.__qaSteps.push({
step,
engine: window.__godsEyeView?.engine?.kind ?? null,
busy: !!document.querySelector('#globe-engine')?.disabled,
busy: Boolean(
document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
),
),
});
window.__qaMark = mark;
});
+30 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { quantile, slope } from './qa-strict-session.mjs';
import { strictArguments } from './qa-strict-harness.mjs';
import { strictArguments, waitForCameraAtRest } from './qa-strict-harness.mjs';
test('slope is the least-squares growth per step', () => {
assert.equal(slope([[0, 5]]), 0);
@@ -45,3 +45,32 @@ test('the runner hands each scenario its layers and its manifest path', () => {
evidence: process.env.QA_STRICT_EVIDENCE ?? null,
});
});
test('the camera is at rest once a flight has landed and it stays still', async () => {
// A selection's flight holds the inputs off; once they are on, the camera
// still moves one more sample before it stays put.
const poses = [0, 400, 900, 900, 1250, 1250, 1250];
const inputs = [false, false, false, false, true, true, true];
let frame = 0;
const engine = {
camera: { pose: () => ({ position: { x: poses[frame], y: 0, z: 6.4e6 } }) },
input: { cameraInputsEnabled: () => inputs[frame] },
};
const previous = globalThis.window;
globalThis.window = { __godsEyeView: { engine } };
const page = {
evaluate: async (fn) => fn(),
async waitForFunction(fn) {
for (; frame < poses.length; frame++) if (fn()) return;
throw new Error('timed out');
},
};
try {
await waitForCameraAtRest(page);
// Not while the flight holds the inputs (sample 3 repeats a pose), nor
// while the camera moves (sample 4).
assert.equal(frame, 5);
} finally {
globalThis.window = previous;
}
});
+8 -6
View File
@@ -121,12 +121,14 @@ try {
// place every frame; it never empties and refills a collection).
// --baseline records the pre-contract build, whose PointPrimitive
// collections sat in scene.primitives and were rebuilt by removeAll.
const engine = window.__godsEyeView.engine;
const createMoverSet = engine.movers.createSet.bind(engine.movers);
engine.movers.createSet = (options) => {
if (/^traffic-(fill|outline)$/.test(options?.id ?? '')) rebuilds++;
return createMoverSet(options);
};
if (!baseline) {
const engine = window.__godsEyeView.engine;
const createMoverSet = engine.movers.createSet.bind(engine.movers);
engine.movers.createSet = (options) => {
if (/^traffic-(fill|outline)$/.test(options?.id ?? '')) rebuilds++;
return createMoverSet(options);
};
}
function discoverCollections() {
if (!baseline) return;
for (let i = 0; i < scene.primitives.length; i++) {
+1 -1
View File
@@ -201,7 +201,7 @@ export async function sampleTransitPixels(page, palette) {
?.getDetectionDiagnostics?.()
?.calloutRects?.filter((r) => r.alpha > 0.01) || [];
if (state._selectedKey) {
const card = overlay?.getOverlayPaintRect(
const card = overlay?.getPaintRect(
'transit-selected',
state._selectedKey,
);
+2 -3
View File
@@ -8,12 +8,11 @@ export async function runTransitHeadingRegression(base, check) {
base = transitQaBase(base);
const browser = await puppeteer.launch({
headless: true,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--use-angle=metal',
...(process.platform === 'darwin' ? ['--use-angle=metal'] : []),
'--enable-gpu',
],
});
+2 -3
View File
@@ -11,10 +11,9 @@ const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
await mkdir(shots, { recursive: true });
const browser = await puppeteer.launch({
headless: true,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--use-angle=metal',
...(process.platform === 'darwin' ? ['--use-angle=metal'] : []),
'--enable-gpu',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
+1 -1
View File
@@ -1657,7 +1657,7 @@ try {
const t0 = performance.now();
const tick = () => {
const entry = state._vehicles.get(key);
const rect = overlay.getOverlayPaintRect('transit-selected', key);
const rect = overlay.getPaintRect('transit-selected', key);
if (entry?.marker) {
const view = scene.camera.viewMatrix;
const proj = scene.camera.frustum.projectionMatrix;
+15 -31
View File
@@ -1,6 +1,12 @@
import assert from 'node:assert/strict';
import { fromDegrees } from '../src/geo/cartesian3.js';
import { createStrict, localPreview, openPage, waitForEngine } from './qa-strict-harness.mjs';
import {
clickEmptyGround,
createStrict,
localPreview,
openPage,
waitForEngine,
} from './qa-strict-harness.mjs';
import { installTransitFixture } from './fixtures/layers/transit.mjs';
import {
combinedPayloads,
@@ -128,36 +134,14 @@ await strict.run({}, async (b) => {
);
// A click on empty ground (nothing picked around it, nothing covering it).
const clickEmpty = async () => {
const handle = await p.waitForFunction(
() => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const box = canvas.getBoundingClientRect();
for (let y = 230; y < 600; y += 9)
for (let x = 440; x < 840; x += 9) {
if (
!canvas.contains(
document.elementFromPoint(box.left + x, box.top + y),
)
)
continue;
let free = true;
for (const [dx, dy] of [
[0, 0],
[8, 0],
[-8, 0],
[0, 8],
[0, -8],
])
if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false;
if (free) return { x: box.left + x, y: box.top + y };
}
return false;
},
{ timeout: 15000 },
);
const at = await handle.jsonValue();
await p.mouse.click(at.x, at.y);
await clickEmptyGround(p, {
left: 440,
top: 230,
right: 840,
bottom: 600,
step: 9,
radius: 8,
});
await p.waitForFunction(
() =>
!window.qaUrban
+31 -1
View File
@@ -308,7 +308,11 @@ await strict.run({}, async (b) => {
engine: window.__godsEyeView?.engine?.kind ?? null,
picker: document.querySelector('#globe-engine')?.value ?? null,
focus: document.activeElement?.id ?? null,
busy: document.querySelector('#globe-engine')?.disabled ?? null,
busy: Boolean(
document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
),
),
frame: Boolean(document.querySelector('.engine-switch-frame')),
loading: document.querySelector('#loading-screen')?.className ?? null,
}));
@@ -343,6 +347,9 @@ await strict.run({}, async (b) => {
let selected = null;
for (const { kind, via, click: id } of LEGS) {
if (via) {
const poseBefore = await p.evaluate(() =>
window.__godsEyeView.engine.camera.pose(),
);
const reads = { ...fixture.asked };
const sighting = byId.get(selected);
if (via === 'keyboard') {
@@ -387,6 +394,29 @@ await strict.run({}, async (b) => {
console.log('PASS', kind, 'picker click closed the card');
selected = null;
}
const poseAfter = await p.evaluate(() =>
window.__godsEyeView.engine.camera.pose(),
);
const positionErrorM = Math.hypot(
...['x', 'y', 'z'].map(
(axis) => poseAfter.position[axis] - poseBefore.position[axis],
),
);
const angleError = (key) =>
Math.abs(
Math.atan2(
Math.sin(poseAfter[key] - poseBefore[key]),
Math.cos(poseAfter[key] - poseBefore[key]),
),
);
await strict.check(
p,
LAYER,
'switch preserves the camera position and orientation',
positionErrorM < 1 &&
['heading', 'pitch', 'roll'].every((key) => angleError(key) < 1e-4),
{ poseBefore, poseAfter, positionErrorM },
);
await frame();
}
+54 -27
View File
@@ -2014,8 +2014,13 @@ async function main() {
await mil.update(v);
return {
groundSnap, milGroundSnap, detectable, airSnap, groundAgainSnap,
droppedGone: !snap('aaa066'),
milDroppedGone: !snap('bbb166'),
// Removed contacts can leave hidden slots in an engine's sprite pool.
droppedGone: !window.__godsEyeView.layers
.drawn('flights')
.some((row) => row.id === 'aaa066'),
milDroppedGone: !window.__godsEyeView.layers
.drawn('military')
.some((row) => row.id === 'bbb166'),
};
});
const fmtSnap = (s) => (s ? `show=${s.show} scale=${s.scale.toFixed(3)} rgba=(${s.red.toFixed(2)},${s.green.toFixed(2)},${s.blue.toFixed(2)},${s.alpha.toFixed(2)})` : 'missing');
@@ -2296,12 +2301,28 @@ async function main() {
try { p = coll.get(i); } catch { continue; }
if (!p) continue;
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
if (p.modelMatrix && typeof p.ready !== 'undefined' && p.id === id) found = p;
// A hidden pool slot can keep a removed contact's id: prefer
// the model shown.
if (p.modelMatrix && typeof p.ready !== 'undefined' && p.id === id && (!found || !found.show)) found = p;
}
};
walk(v.scene.primitives);
return found;
};
window.__g3dCountModels = function (id) {
let n = 0;
const walk = (coll) => {
for (let i = 0; i < coll.length; i++) {
let p;
try { p = coll.get(i); } catch { continue; }
if (!p) continue;
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
if (p.modelMatrix && typeof p.ready !== 'undefined' && p.id === id) n++;
}
};
walk(window.__godsEyeView.viewer.scene.primitives);
return n;
};
});
// (a)+(b): both grounded planes become models under the cap (the old code
@@ -2316,8 +2337,19 @@ async function main() {
skip('ground-3d: grounded planes are model-eligible and modeled (both layers)',
`independent GLB control unavailable (${glbControlDetail}); grounded GLB readiness is not observable`);
} else {
// On a miss, say what each layer's own model handle holds.
const g3dWhy = g3dModelsUp ? null : await evalPage(() => {
const dm = window.__godsEyeView.dataManager;
const of = (layerId, id) => {
const { model } = dm.layers.get(layerId).module.testing._aircraftHandlesForTest(id);
return model
? `${id}: handle ready=${model.ready} show=${model.show} failed=${model.failed}, engine model ready=${window.__g3dFindModel(id)?.ready} show=${window.__g3dFindModel(id)?.show} (${window.__g3dCountModels?.(id)} with this id)`
: `${id}: no model handle`;
};
return `${of('flights', 'aaa077')}; ${of('military', 'bbb177')}`;
});
record('ground-3d: grounded planes are model-eligible and modeled (both layers)', g3dModelsUp,
g3dModelsUp ? 'fleet models up (ready+shown)' : 'models never became ready+shown');
g3dModelsUp ? 'fleet models up (ready+shown)' : `models never became ready+shown (${g3dWhy})`);
}
if (g3dModelsUp) {
@@ -2767,8 +2799,10 @@ async function main() {
});
await nextFrames(3);
const angDiff = (a, b) => Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b)));
const probe = async (id, course) => {
const bb = findBB(id);
const probe = async (layerId, id, course) => {
// Tamper with the layer's own glyph handle: a write to the engine's
// billboard bypasses the layer's sprite cache, so no pass rewrites it.
const bb = dm.layers.get(layerId).module.testing._aircraftHandlesForTest(id).sprite;
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
@@ -2813,8 +2847,8 @@ async function main() {
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0;
return {
flights: await probe('aaa002', flightCourse),
military: await probe('bbb101', militaryCourse),
flights: await probe('flights', 'aaa002', flightCourse),
military: await probe('military', 'bbb101', militaryCourse),
};
});
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; ${arrivalAngleViolated(a) ? 'angle violation (not retried); ' : ''}${Number.isFinite(a.dMoveEnd) ? `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° ` : ''}${Number.isFinite(a.dReveal) ? `reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° ` : ''}elapsed=${Number(a.elapsedMs).toFixed(1)} ms attempts=${a.attempts}`
@@ -3780,24 +3814,13 @@ async function main() {
// (a) FLEET: retain the adversarial flags until this contact has passed
// through its installed fleet update and that frame has completed.
const findFleetModel = (id) => {
let found = null;
const walk = (coll) => {
const len = coll.length;
for (let i = 0; i < len; i++) {
let p; try { p = coll.get(i); } catch { continue; }
if (!p) continue;
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
if (p.activeAnimations !== undefined && p.minimumPixelSize !== undefined && p.id === id) found = p;
}
};
walk(v.scene.primitives);
return found;
};
// The layer's own model handle: the engine's Model is drawn from it,
// and a flag planted on the engine's object never reaches the layer.
const fleetModelOf = (id) => fl.testing._aircraftHandlesForTest(id).model;
fl.setParams({ models3d: true });
const fleetDeadline = Date.now() + 20000;
let fleetModel = null;
while (Date.now() < fleetDeadline && !(fleetModel = findFleetModel('aaa097'))) {
while (Date.now() < fleetDeadline && !(fleetModel = fleetModelOf('aaa097'))) {
await window.__dfSettle(500);
}
if (!fleetModel) {
@@ -3849,7 +3872,7 @@ async function main() {
return;
}
const currentBb = window.__dfFindBB('aaa097');
const currentModel = findFleetModel('aaa097');
const currentModel = fleetModelOf('aaa097');
sample = {
fleetTickCompleted: true,
fleetFrame: v.scene.frameState.frameNumber,
@@ -3859,7 +3882,7 @@ async function main() {
fleetSeeded: seededFleet,
fleetH: currentBb ? window.__dfCarto(currentBb.position).h : null,
};
if (currentBb !== bb || currentModel !== fleetModel || fleetModel.isDestroyed()
if (currentBb !== bb || currentModel !== fleetModel
|| fl.getTrackedInfo()) sample.error = 'fleet loading fixture changed before its completed frame';
});
armed = true;
@@ -4032,13 +4055,17 @@ async function main() {
const taxiMeshM = gfns?.cachedMeshFloor?.(taxiCarto.lat, taxiCarto.lon) ?? null;
const heldOwns = ns._driveFleetModelHandoffForTest({ icao24: holdIcao, position: taxiPos, course: 90 });
const heldH = window.__dfModelHeight(holdIcao);
const heldBb = !!window.__dfFindBB(holdIcao)?.show;
const heldBb = !!window.__godsEyeView.layers
.drawn('flights')
.find((row) => row.id === holdIcao && !row.trackedModel)?.show;
// 3. ~385 m out the memory stops describing anywhere this contact has
// been. It is released rather than stretched, and the gate takes over.
const farPos = Cesium.Cartesian3.fromDegrees(base.lon + 0.004, base.lat, base.h);
const releasedOwns = ns._driveFleetModelHandoffForTest({ icao24: holdIcao, position: farPos, course: 90 });
const releasedBb = !!window.__dfFindBB(holdIcao)?.show;
const releasedBb = !!window.__godsEyeView.layers
.drawn('flights')
.find((row) => row.id === holdIcao && !row.trackedModel)?.show;
fl.setParams({ models3d: false });
await window.__dfSettle(300);
+10
View File
@@ -0,0 +1,10 @@
import { parse } from 'vue/compiler-sfc';
/** JavaScript blocks with their original line numbers; templates are not code. */
export function vueScripts(source, filename) {
const { descriptor, errors } = parse(source, { filename });
if (errors.length) throw errors[0];
return [descriptor.script, descriptor.scriptSetup]
.filter(Boolean)
.map((block) => '\n'.repeat(block.loc.start.line - 1) + block.content);
}
@@ -245,6 +245,7 @@ export function createNavaraWorldAnnotationRenderer(
export function createNavaraAnnotationRenderer(engine, views, { screen } = {}) {
const world = createNavaraWorldAnnotationRenderer(engine, views);
const renderer = createHybridAnnotationRenderer(null, {
engine,
world,
screenScene: screenSceneFromEngine(engine),
screen,
@@ -12,6 +12,7 @@ import {
createNavaraWorldAnnotationRenderer,
drapedParts,
} from './navaraAnnotationRenderer.js';
import { createSceneServices } from '../data/sceneServices.js';
import { screenSceneFromEngine } from './screenScene.js';
for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) {
@@ -265,3 +266,39 @@ test('marks on the Navara board are drawn, horizon-culled and survive an engine
engine.fake.frame();
assert.equal(live().length, 0);
});
test('screen annotations read the attached engines tracked card footprint', async (t) => {
const { store, groups } = board(t);
const engine = createFakeEngine({
longitude: 0,
latitude: 0,
altitude: 10000,
});
const services = createSceneServices(engine);
const queries = [];
services.overlay.getPaintRect = (...args) => {
queries.push(args);
return { x: 0, y: 0, w: 4000, h: 4000 };
};
const { views } = fakeViews();
const renderer = createNavaraAnnotationRenderer(engine, views, {
screen: { store, activeTrackedReadoutId: () => 'flight:test' },
});
t.after(() => {
renderer.destroy();
services.destroy();
engine.destroy();
});
renderer.add({
id: 'overlap',
type: 'label',
color: 'primary',
label: 'Overlap',
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
});
engine.fake.frame();
await settle();
assert.deepEqual(queries.at(-1), ['tracked', 'flight:test']);
assert.ok(Number(groups()[0].getAttribute('opacity')) < 1);
});
+11 -5
View File
@@ -2297,13 +2297,19 @@ export function createAnnotationResolver({
/**
* Whether a depth pick landed on the camera itself: something drawn into
* the depth buffer at the near plane (Navara's screen-space preview lines
* and sprites) reads back as a point a metre or so from the camera. Such a
* pick is a miss; the ray's globe hit answers instead.
* and sprites) reads back as a point near the camera, the nearer the lower
* the camera. Such a pick is a miss; the ray's globe hit answers instead.
* The scale is the rendered surface under the ray, not sea level, so a
* camera just above high terrain keeps its ground.
*/
function depthAtCamera(engine, screen, hit) {
const camera = engine.camera.livePosition;
const ground = engine.camera.pickEllipsoid(screen);
const reference = ground ? geo.cartesian3.distance(ground, camera) : 0;
let ground = engine.picker.pickGlobe(screen);
if (!isPickedWorldPosition(ground))
ground = engine.camera.pickEllipsoid(screen);
const reference = isPickedWorldPosition(ground)
? geo.cartesian3.distance(ground, camera)
: 0;
return geo.cartesian3.distance(hit, camera) < Math.max(2, 0.01 * reference);
}
@@ -2340,8 +2346,8 @@ export function createAnnotationResolver({
const hit = engine.picker.pickPosition(screen);
return hit && !depthAtCamera(engine, screen, hit) ? hit : undefined;
},
() => engine.camera.pickEllipsoid(screen),
() => engine.picker.pickGlobe(screen),
() => engine.camera.pickEllipsoid(screen),
];
for (const stage of stages) {
let cart = null;
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as geo from '../geo/index.js';
import { pickWorldFromScreen } from './annotationResolver.js';
// Camera 30 m above 4000 m terrain, looking straight down: the depth pick
// hits the ground 30 m away; the ellipsoid lies 4030 m away.
test('a depth pick on high terrain close to the camera is kept', () => {
const camera = geo.cartesian3.fromDegrees(86.9, 27.9, 4030);
const ground = geo.cartesian3.fromDegrees(86.9, 27.9, 4000);
const seaLevel = geo.cartesian3.fromDegrees(86.9, 27.9, 0);
const engine = {
host: { size: () => ({ width: 100, height: 100 }) },
picker: {
pickPositionSupported: true,
pickPosition: () => geo.cartesian3.clone(ground),
pickGlobe: () => geo.cartesian3.clone(ground),
},
camera: {
livePosition: camera,
pickEllipsoid: () => geo.cartesian3.clone(seaLevel),
},
};
const picked = pickWorldFromScreen(engine, 0.5, 0.5);
assert.ok(
Math.abs(picked.height - 4000) < 1,
`picked height ${picked.height}`,
);
});
test('a near-camera depth artifact falls back to terrain before sea level', () => {
const engine = {
host: { size: () => ({ width: 100, height: 100 }) },
picker: {
pickPositionSupported: true,
pickPosition: () => geo.cartesian3.fromDegrees(86.9, 27.9, 4029),
pickGlobe: () => geo.cartesian3.fromDegrees(86.9, 27.9, 4000),
},
camera: {
livePosition: geo.cartesian3.fromDegrees(86.9, 27.9, 4030),
pickEllipsoid: () => geo.cartesian3.fromDegrees(86.9, 27.9, 0),
},
};
assert.ok(Math.abs(pickWorldFromScreen(engine, 0.5, 0.5).height - 4000) < 1);
});
test('a far camera still treats a near-plane depth hit as a miss', () => {
// 100 km up: a preview drawn at the near plane reads back 50 m away.
const camera = geo.cartesian3.fromDegrees(86.9, 27.9, 100_000);
const engine = {
host: { size: () => ({ width: 100, height: 100 }) },
picker: {
pickPositionSupported: true,
pickPosition: () => geo.cartesian3.fromDegrees(86.9, 27.9, 99_950),
pickGlobe: () => geo.cartesian3.fromDegrees(86.9, 27.9, 4000),
},
camera: {
livePosition: camera,
pickEllipsoid: () => geo.cartesian3.fromDegrees(86.9, 27.9, 0),
},
};
assert.ok(Math.abs(pickWorldFromScreen(engine, 0.5, 0.5).height - 4000) < 1);
});
+187 -325
View File
@@ -5,7 +5,6 @@ import { createApplicationCyclones } from './layers/cyclones.js';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { rendererFor } from './layers/renderers.js';
import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createApplicationFlights } from './layers/flights.js';
@@ -34,7 +33,7 @@ import { createApplicationGhostMaps } from './layers/ghostMaps.js';
import { createApplicationDroneAirspace } from './layers/droneAirspace.js';
import { createApplicationWildlifeSightings } from './layers/wildlifeSightings.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { OSM_OVERLAYS, createOsmOverlayLayers } from '../data/osmOverlays.js';
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureSessionLayer } from '../layers/infrastructure/index.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
@@ -113,351 +112,214 @@ export function createApplicationCatalog({
});
}
}
// Shared instances (awareness and launches read others) are made once,
// when the first layer that needs them is built.
const once = (create) => {
let value;
return () => (value ??= create());
};
const sessionModel = (id, create) => {
if (!layerSession) return create();
const models = (layerSession.models ??= new Map());
if (!models.has(id)) models.set(id, create());
return models.get(id);
};
const flights = once(() =>
sessionModel('flights', () =>
createApplicationFlights({
const flights = sessionModel('flights', () =>
createApplicationFlights({
surface,
source: sources.flights,
militaryRegistry,
resolveAsset,
}),
);
const military = sessionModel('military', () =>
createApplicationMilitary({
surface,
source: sources.military,
militaryRegistry,
resolveAsset,
}),
);
const vessels = sessionModel('ais-live-vessels', () =>
createApplicationVessels({
source: sources.vessels,
options: vesselOptions,
}),
);
const installations = sessionModel('military-installations', () =>
createApplicationInstallations({
surface,
source: sources.installations,
}),
);
const satellites = sessionModel('satellites', () =>
createApplicationSatellites({
source: sources.satellites,
}),
);
const layers = [
sessionModel('bhote-koshi-2026', () =>
createBhoteKoshiEventLayer({
createNativeRenderer: (engine) =>
rendererFor('bhote-koshi-2026', engine),
}),
),
sessionModel('bhote-koshi-locator', () =>
createBhoteKoshiLocatorLayer({
boundaryResolver: nepalBoundaryResolver,
createNativeRenderer: (engine, data) =>
rendererFor('bhote-koshi-locator', engine, { data }),
}),
),
flights,
military,
sessionModel('local-adsb', () =>
createApplicationLocalAdsb({
surface,
source: sources.flights,
militaryRegistry,
resolveAsset,
// QA fixture receivers (?qaFeed=local-adsb:fixture, dev only).
...(sources.localAdsbFeeds ? { feeds: sources.localAdsbFeeds } : {}),
enrichment: sources.flights,
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
),
);
const military = once(() =>
sessionModel('military', () =>
createApplicationMilitary({
sessionModel('earthquakes', () =>
createApplicationEarthquakes({ source: sources.earthquakes }),
),
sessionModel('air-quality', () =>
createApplicationAirQuality({ source: sources['air-quality'] }),
),
sessionModel('fire-perimeters', () =>
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
),
sessionModel('gnss-integrity', () =>
createApplicationGnssIntegrity({
source: sources['gnss-integrity'],
windowMs: sources['gnss-integrity'].windowMs,
}),
),
sessionModel('s2-ghostmaps', () =>
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
),
sessionModel('drone-airspace', () =>
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
),
sessionModel('wildlife-sightings', () =>
createApplicationWildlifeSightings({
source: sources['wildlife-sightings'],
groundFloor: surface.groundFloor,
}),
),
sessionModel('alpr-cameras', () =>
createApplicationAlpr({ surface, source: sources.alpr }),
),
sessionModel('street-level', () =>
createApplicationStreetLevel({
surface,
source: sources.military,
militaryRegistry,
resolveAsset,
nativeRenderer: (engine) => rendererFor('street-level', engine),
sources: { mapillary: sources.mapillary },
providers: streetLevelProviders,
}),
),
);
const vessels = once(() =>
sessionModel('ais-live-vessels', () =>
createApplicationVessels({
source: sources.vessels,
options: vesselOptions,
satellites,
sessionModel('rocket-launches', () =>
createApplicationLaunches({
source: sources.launches,
satellites: satellites,
}),
),
);
const installations = once(() =>
sessionModel('military-installations', () =>
createApplicationInstallations({
sessionModel('traffic', () =>
createApplicationTraffic({
source: sources.traffic,
surface,
source: sources.installations,
}),
),
);
const satellites = once(() =>
sessionModel('satellites', () =>
createApplicationSatellites({
source: sources.satellites,
sessionModel('cctv', () =>
createApplicationCctv({ surface, source: sources.cctv }),
),
sessionModel('radio', () =>
createApplicationRadio({ surface, source: sources.radio }),
),
sessionModel('transit', () =>
createApplicationTransit({ surface, source: sources.transit }),
),
sessionModel('bikeshare', () =>
createApplicationBikeshare({ source: sources.bikeshare }),
),
sessionModel('directions', () =>
createApplicationDirections({
source: sources.directions,
surface,
}),
),
);
// Catalog order: [layer id(s), construction]. Only the layers the
// engine can show are constructed (renderers.js).
const entries = [
[
'bhote-koshi-2026',
() =>
sessionModel('bhote-koshi-2026', () =>
createBhoteKoshiEventLayer({
createNativeRenderer: (engine) =>
rendererFor('bhote-koshi-2026', engine),
}),
),
],
[
'bhote-koshi-locator',
() =>
sessionModel('bhote-koshi-locator', () =>
createBhoteKoshiLocatorLayer({
boundaryResolver: nepalBoundaryResolver,
createNativeRenderer: (engine, data) =>
rendererFor('bhote-koshi-locator', engine, { data }),
}),
),
],
['flights', flights],
['military', military],
[
'local-adsb',
() =>
sessionModel('local-adsb', () =>
createApplicationLocalAdsb({
surface,
// QA fixture receivers (?qaFeed=local-adsb:fixture, dev only).
...(sources.localAdsbFeeds
? { feeds: sources.localAdsbFeeds }
: {}),
enrichment: sources.flights,
displayParams: () => flights().getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
),
],
[
'earthquakes',
() =>
sessionModel('earthquakes', () =>
createApplicationEarthquakes({ source: sources.earthquakes }),
),
],
[
'air-quality',
() =>
sessionModel('air-quality', () =>
createApplicationAirQuality({ source: sources['air-quality'] }),
),
],
[
'fire-perimeters',
() =>
sessionModel('fire-perimeters', () =>
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
),
],
[
'gnss-integrity',
() =>
sessionModel('gnss-integrity', () =>
createApplicationGnssIntegrity({
source: sources['gnss-integrity'],
windowMs: sources['gnss-integrity'].windowMs,
}),
),
],
[
's2-ghostmaps',
() =>
sessionModel('s2-ghostmaps', () =>
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
),
],
[
'drone-airspace',
() =>
sessionModel('drone-airspace', () =>
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
),
],
[
'wildlife-sightings',
() =>
sessionModel('wildlife-sightings', () =>
createApplicationWildlifeSightings({
source: sources['wildlife-sightings'],
groundFloor: surface.groundFloor,
}),
),
],
[
'alpr-cameras',
() =>
sessionModel('alpr-cameras', () =>
createApplicationAlpr({ surface, source: sources.alpr }),
),
],
[
'street-level',
() =>
sessionModel('street-level', () =>
createApplicationStreetLevel({
surface,
nativeRenderer: (engine) => rendererFor('street-level', engine),
sources: { mapillary: sources.mapillary },
providers: streetLevelProviders,
}),
),
],
['satellites', satellites],
[
'rocket-launches',
() =>
sessionModel('rocket-launches', () =>
createApplicationLaunches({
source: sources.launches,
satellites: satellites(),
}),
),
],
[
'traffic',
() =>
sessionModel('traffic', () =>
createApplicationTraffic({
source: sources.traffic,
surface,
}),
),
],
[
'cctv',
() =>
sessionModel('cctv', () =>
createApplicationCctv({ surface, source: sources.cctv }),
),
],
[
'radio',
() =>
sessionModel('radio', () =>
createApplicationRadio({ surface, source: sources.radio }),
),
],
[
'transit',
() =>
sessionModel('transit', () =>
createApplicationTransit({ surface, source: sources.transit }),
),
],
[
'bikeshare',
() =>
sessionModel('bikeshare', () =>
createApplicationBikeshare({ source: sources.bikeshare }),
),
],
[
'directions',
() =>
sessionModel('directions', () =>
createApplicationDirections({
source: sources.directions,
surface,
}),
),
],
[
'recent-imagery',
() =>
sessionModel('recent-imagery', () =>
createApplicationRecentImagery({
source: sources['recent-imagery'],
}),
),
],
[
'osm-places',
() => sessionModel('osm-places', () => createApplicationOsmPlaces()),
],
['ais-live-vessels', vessels],
['military-installations', installations],
[
'military-awareness',
() =>
sessionModel('military-awareness', () =>
createApplicationAwareness({
flights: flights(),
military: military(),
vessels: vessels(),
installations: installations(),
}),
),
],
[
'wind',
() =>
sessionModel('wind', () =>
createWindLayer({
feed: sources.wind,
clock: weatherClock,
createNativeRendering: (engine, options) =>
rendererFor('wind', engine, options),
}),
),
],
...['weather-radar', 'weather-satellite', 'weather-lightning'].map(
(id) => [
id,
() =>
sessionModel(id, () =>
createWeatherLayer({
feed: sources.weather,
id,
clock: weatherClock,
createNativeRendering: (engine, options) =>
rendererFor(id, engine, options),
sessionModel('recent-imagery', () =>
createApplicationRecentImagery({
source: sources['recent-imagery'],
}),
),
sessionModel('osm-places', () => createApplicationOsmPlaces()),
vessels,
installations,
sessionModel('military-awareness', () =>
createApplicationAwareness({
flights: flights,
military: military,
vessels: vessels,
installations: installations,
}),
),
sessionModel('wind', () =>
createWindLayer({
feed: sources.wind,
clock: weatherClock,
createNativeRendering: (engine, options) =>
rendererFor('wind', engine, options),
}),
),
...['weather-radar', 'weather-satellite', 'weather-lightning'].map((id) =>
sessionModel(id, () =>
createWeatherLayer({
feed: sources.weather,
id,
clock: weatherClock,
createNativeRendering: (engine, options) =>
rendererFor(id, engine, options),
}),
),
),
sessionModel('weather-cyclones', () =>
createApplicationCyclones({
feed: sources.cyclones,
createNativeRendering: (engine, options) =>
rendererFor('weather-cyclones', engine, options),
}),
),
...sessionModel('osm-overlays', () => createOsmOverlayLayers()),
...sessionModel('local-infrastructure', () =>
createInfrastructureLayers(localGeoJsonServices, (config, services) =>
createInfrastructureSessionLayer(
config,
services,
(engine, descriptor, ops) =>
rendererFor(config.id, engine, {
config: descriptor,
services: ops,
}),
),
],
),
),
),
sessionModel('telegeography-submarine-cables', () =>
createApplicationCables({ source: sources.cables, session: true }),
),
sessionModel('local-firms', () =>
createApplicationFirms({
surface,
id: 'local-firms',
name: 'FIRMS Active Fires',
icon: '▲',
source: 'NASA FIRMS · LIVE',
feed: sources.firms,
}),
),
[
'weather-cyclones',
() =>
sessionModel('weather-cyclones', () =>
createApplicationCyclones({
feed: sources.cyclones,
createNativeRendering: (engine, options) =>
rendererFor('weather-cyclones', engine, options),
}),
),
],
[
OSM_OVERLAYS.map(({ id }) => id),
() => sessionModel('osm-overlays', () => createOsmOverlayLayers()),
],
[
['local-datacenters', 'local-dams'],
() =>
sessionModel('local-infrastructure', () =>
createInfrastructureLayers(
localGeoJsonServices,
(config, services) =>
createInfrastructureSessionLayer(
config,
services,
(engine, descriptor, ops) =>
rendererFor(config.id, engine, {
config: descriptor,
services: ops,
}),
),
),
),
],
[
'telegeography-submarine-cables',
() =>
sessionModel('telegeography-submarine-cables', () =>
createApplicationCables({ source: sources.cables, session: true }),
),
],
[
'local-firms',
() =>
sessionModel('local-firms', () =>
createApplicationFirms({
surface,
id: 'local-firms',
name: 'FIRMS Active Fires',
icon: '▲',
source: 'NASA FIRMS · LIVE',
feed: sources.firms,
}),
),
],
];
const layers = entries.flatMap(([, create]) => create());
const catalog = createLayerCatalog(layers, metadata);
return Object.freeze({
...catalog,
-2
View File
@@ -46,8 +46,6 @@ export async function createApplicationControls({
const styleManager = new Controls(viewer, {
services: {
...sharedServices,
// The catalog's layers; the ones this engine cannot show (Navara)
// are empty stand-ins.
...catalogControlServices(catalog),
},
requestServices: operations.requests,
+11 -4
View File
@@ -8,6 +8,9 @@ export const ENGINE_STORAGE_KEY = 'gev.engine';
*/
export const ENGINE_CHOICES = Object.freeze(['cesium', 'navara', 'navara-hd']);
/** The choice without one made, and the one a failed start falls back to. */
export const DEFAULT_ENGINE_CHOICE = ENGINE_CHOICES[0];
/** The engine a choice runs: both Navara choices are the Navara engine. */
export const engineOfChoice = (choice) =>
choice === 'cesium' ? 'cesium' : 'navara';
@@ -21,12 +24,16 @@ const known = (value) => {
/**
* The globe choice for this page: `?engine=cesium|navara|navara-hd`, else
* the stored `gev.engine`, else Cesium. Navara is experimental; an unknown or
* the stored `gev.engine`, else `fallback` (Cesium). Navara is experimental; an unknown or
* unreadable value never selects it.
* @param {{ search?: string, storage?: { getItem(key: string): string | null } | null }} [where]
* @param {{ search?: string, storage?: { getItem(key: string): string | null } | null, fallback?: string }} [where]
* @returns {'cesium' | 'navara' | 'navara-hd'}
*/
export function resolveEngineKind({ search = '', storage = null } = {}) {
export function resolveEngineKind({
search = '',
storage = null,
fallback = DEFAULT_ENGINE_CHOICE,
} = {}) {
const fromQuery = known(new URLSearchParams(search).get('engine'));
if (fromQuery) return fromQuery;
let stored = null;
@@ -35,5 +42,5 @@ export function resolveEngineKind({ search = '', storage = null } = {}) {
} catch {
// Storage can be blocked (private mode, sandboxed frames).
}
return known(stored) ?? 'cesium';
return known(stored) ?? known(fallback) ?? DEFAULT_ENGINE_CHOICE;
}
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSceneHost } from './sceneHost.js';
import { resolveEngineKind, ENGINE_STORAGE_KEY } from './engineChoice.js';
test('a history visit without ?engine= keeps the engine the page started with', async () => {
// Tab A: no ?engine= in its address, started Navara from the stored choice.
const store = new Map([[ENGINE_STORAGE_KEY, 'navara']]);
const storage = { getItem: (key) => store.get(key) ?? null };
const search = '?view=1'; // no engine parameter
const initial = resolveEngineKind({ search, storage });
const requested = () => resolveEngineKind({ search, fallback: initial });
assert.equal(initial, 'navara');
const entered = [];
const switcher = createSceneHost().createSwitcher({
kind: initial,
requested,
signal: new AbortController().signal,
leave: async () => {},
enter: async (kind) => entered.push(kind),
address: { select: () => null, commit() {}, restore() {} },
settle: async () => {},
});
// Tab B picks Cesium and remembers it in the shared storage.
store.set(ENGINE_STORAGE_KEY, 'cesium');
// Tab A: a popstate (a pasted share link that changes only the hash).
await switcher.reconcile();
assert.deepEqual(entered, [], `tab A switched to ${entered.join(',')}`);
assert.equal(switcher.state().engine, 'navara');
});
+1
View File
@@ -29,6 +29,7 @@ test('a session retains layer intentions through handoff but never after page te
entry,
enabled: true,
durable: true,
visit: false,
});
resumeSession(session);
assert.deepEqual(calls, ['suspend', 'viewport', 'resume']);
-1
View File
@@ -12,7 +12,6 @@ import * as selection from '../../data/selectionDispatcher.js';
import { rendererFor } from './renderers.js';
import * as readouts from '../../hudReadouts.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationVessels({
source,
options = {},
-5
View File
@@ -9,11 +9,6 @@ import * as picking from '../../data/pickRegistry.js';
import { keyholeLabelAlphaFromGeometry } from '../../celestialRing.js';
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
/**
* Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the manifest
* (render/display.js on every engine).
*/
export function createApplicationAlpr({
surface,
source,
-5
View File
@@ -4,11 +4,6 @@ import * as render from '../../renderGovernor.js';
import * as picking from '../../data/pickRegistry.js';
import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js';
/**
* Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the manifest
* (render/display.js on every engine).
*/
export function createApplicationBikeshare({ source }) {
return createBikeshareLayer({
source,
-5
View File
@@ -8,11 +8,6 @@ import * as picking from '../../data/pickRegistry.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as render from '../../renderGovernor.js';
/**
* Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the manifest
* (render/display.js on every engine).
*/
export function createApplicationCctv({
surface,
source,
-1
View File
@@ -12,7 +12,6 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as recession from '../../data/aircraftRecession.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationFlights({
surface,
source,
-1
View File
@@ -12,7 +12,6 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as recession from '../../data/aircraftRecession.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationMilitary({
surface,
source,
-5
View File
@@ -6,11 +6,6 @@ import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js';
import * as picking from '../../data/pickRegistry.js';
/**
* Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the manifest
* (render/display.js on every engine).
*/
export function createApplicationInstallations({ surface, source }) {
const { groundFloor: ground, anchors } = surface;
return createInstallationsLayer({
+5 -1
View File
@@ -31,6 +31,9 @@ import { satelliteFixture } from '../../bench/satelliteFixture.js';
import { createSatelliteSource } from '../../layers/satellites/source.js';
import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js';
// The largest synthetic fleet used by the QA scenarios.
const MAX_QA_COUNT = 10_000;
const KINDS = Object.freeze({
flights: new Set(['synthetic']),
military: new Set(['synthetic']),
@@ -49,7 +52,8 @@ export function parseQaFeed(search = '') {
const count = rawCount === undefined ? null : Number(rawCount);
if (
!KINDS[layer]?.has(kind) ||
(count !== null && !(Number.isInteger(count) && count > 0))
(count !== null &&
!(Number.isSafeInteger(count) && count > 0 && count <= MAX_QA_COUNT))
)
throw new TypeError(
`qaFeed: unknown entry "${item}" (flights:synthetic[:count], military:synthetic[:count], vessels:fixture, satellites:fixture, launches:fixture, local-adsb:fixture)`,
+16
View File
@@ -133,3 +133,19 @@ test('vessel fixture answers a relative track URL and retains its full snapshot'
});
assert.equal((await vessels.getSnapshot({})).records.length, 12000);
});
test('fixture counts cannot exceed the QA fleet budget', () => {
for (const count of [
'10001',
'1e9',
'9007199254740992',
'Infinity',
'0',
'1.5',
])
assert.throws(
() => parseQaFeed(`?qaFeed=flights:synthetic:${count}`),
TypeError,
);
assert.equal(parseQaFeed('?qaFeed=flights:synthetic:10000')[0].count, 10000);
});
-5
View File
@@ -5,11 +5,6 @@ import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js';
import * as globe from '../../celestialRing.js';
import * as render from '../../renderGovernor.js';
/**
* Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the manifest
* (render/display.js on every engine).
*/
export function createApplicationRadio({
surface,
source,
-1
View File
@@ -4,7 +4,6 @@ import * as geometry from '../../celestialRing.js';
import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js';
import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationLaunches({
source,
satellites,
-1
View File
@@ -8,7 +8,6 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as layerState from '../../data/layerState.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationSatellites({
source,
// Test seam: the renderer factory (default: the engine's, from the
-5
View File
@@ -4,11 +4,6 @@ import * as ground from '../../data/groundFloor.js';
import * as render from '../../renderGovernor.js';
import { rendererFor } from './renderers.js';
/**
* Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the manifest
* (render/display.js on every engine).
*/
export function createApplicationTraffic({ source, surface }) {
return createTrafficLayer({
source,
+5 -1
View File
@@ -7,7 +7,7 @@
// - `viewer()`: a Cesium globe is about to start: the SDK and the widget
// styles;
// - `sdk()`: a feature that still builds on Cesium's entity model (the Nepal
// event and locator, imported KML) is used on Navara: the SDK alone.
// event and locator) is used on Navara: the SDK alone.
export function createCesiumLoader({
document = globalThis.document,
globals = globalThis,
@@ -45,6 +45,9 @@ export function createCesiumLoader({
const ready = () =>
!script ||
Object.getOwnPropertyDescriptor(globals, 'Cesium')?.value != null;
// A page whose markup ran Cesium.js before this loader also carries its
// widget styles (the MCP panel build, which cannot load files by URL).
const preloaded = script && ready();
const sdk = () => {
if (ready()) return Promise.resolve();
sdkPending ??= load('script', `${root()}Cesium.js`).catch((error) => {
@@ -54,6 +57,7 @@ export function createCesiumLoader({
return sdkPending;
};
const styles = () => {
if (preloaded) return Promise.resolve();
stylesPending ??= load('link', `${root()}Widgets/widgets.css`).catch(
(error) => {
stylesPending = null;
+8
View File
@@ -76,6 +76,14 @@ test('the SDK is not loaded where the page already has it or imports its module'
assert.equal(dev.elements.length, 0);
});
test('a page that ran Cesium.js from its markup loads no styles either', async () => {
// The panel build's HTML lists Cesium.js and widgets.css; the panel host
// answers only those, not a stylesheet added later.
const { elements, loader } = fixture({ Cesium: {} });
await loader.viewer();
assert.equal(elements.length, 0);
});
test('a failed Cesium asset load can be retried and preserves the configured base', async () => {
const { elements, globals, loader } = fixture({
CESIUM_BASE_URL: '/custom/',
+120
View File
@@ -0,0 +1,120 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import * as choices from './engineChoice.js';
const source = readFileSync(new URL('../main.js', import.meta.url), 'utf8')
.replace(/^import[\s\S]*?;\n/gm, '')
.replaceAll('import.meta.env', 'env')
.replace(/export \{ application \};/, '');
const settle = async () => {
for (let i = 0; i < 15; i++) await new Promise((r) => setTimeout(r, 0));
};
function boot(t, { search = '', failures = ['navara'] } = {}) {
document.body.innerHTML =
'<div id="loading-screen"><div class="loader-content"><p class="loader-status"></p></div></div>';
const starts = [];
const switches = [];
const toasts = [];
const stored = new Map([[choices.ENGINE_STORAGE_KEY, 'navara']]);
const location = { search, href: `https://example.test/${search}` };
const localStorage = {
getItem: (key) => stored.get(key),
setItem: (key, value) => stored.set(key, value),
};
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const deps = {
...choices,
env: {},
location,
localStorage,
history: { replaceState() {} },
guardCesiumGlobal() {},
t: (key, values = {}) =>
key.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? ''),
describeError: (error) => error.message,
createSessionDiagnostics: () => ({ count() {} }),
createRendererCache: () => ({ clear() {} }),
createLayerSession: () => ({ lifetime }),
useDisplayStore: () => ({ presentGlobeEngine() {} }),
applicationPinia() {},
pageEngineKind: () =>
choices.resolveEngineKind({
search: location.search,
storage: localStorage,
}),
createEngineAddress: () => ({
restore(kind) {
location.search = `?engine=${kind}`;
},
}),
createSceneSwitcher: (options) => {
switches.push(options);
return {
state: () => ({
kind: options.kind,
engine: options.kind,
busy: false,
}),
reconcile: async () => {},
select: async () => {},
};
},
createStandaloneApplication: ({ engineKind }) => ({
async start() {
starts.push(engineKind);
if (failures.includes(engineKind)) throw new Error('Engine failed');
},
async destroy() {},
getState: () => ({
status: failures.includes(engineKind) ? 'failed' : 'ready',
}),
getComponents: () => ({
scene: { engine: {} },
controls: { styleManager: { _showToast: (text) => toasts.push(text) } },
}),
}),
console: { error() {} },
};
new Function(...Object.keys(deps), source)(...Object.values(deps));
return { starts, switches, stored, toasts };
}
test('a failed stored engine falls back to Cesium and remembers the recovery', async (t) => {
const f = boot(t);
await settle();
assert.deepEqual(f.starts, ['navara', 'cesium']);
assert.equal(f.stored.get(choices.ENGINE_STORAGE_KEY), 'cesium');
assert.equal(f.switches[0].kind, 'cesium');
assert.deepEqual(f.toasts, [
'Globe engine failed to start. Running Cesium: Engine failed',
]);
});
test('a failed explicit first engine offers a usable retry picker', async (t) => {
const f = boot(t, { search: '?engine=navara' });
await settle();
assert.deepEqual(f.starts, ['navara']);
const retry = document.querySelector('[data-engine="cesium"]');
assert.ok(retry);
retry.click();
await settle();
assert.deepEqual(f.starts, ['navara', 'cesium']);
assert.equal(f.switches[0].kind, 'cesium');
assert.equal(document.querySelector('.engine-switch-retry'), null);
});
test('a recovered engine switch names the globe that remains running', async (t) => {
const f = boot(t, { search: '?engine=cesium', failures: [] });
await settle();
f.switches[0].onFailure(new Error('Initialization failed'), {
recovered: true,
kind: 'cesium',
});
assert.deepEqual(f.toasts, [
'Globe engine failed to start. Running Cesium: Initialization failed',
]);
});
+6 -5
View File
@@ -9,6 +9,7 @@ import { registerDataCredits } from '../data/dataCredits.js';
import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js';
import { LAYER_MANIFEST } from './layers/renderers.js';
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js';
import { localizeLayerName } from '../ui/layerNames.js';
/** The Navara globe module, loaded on demand. */
const loadNavaraGlobeModule = async () =>
@@ -26,8 +27,10 @@ export function navaraModeNotice(manifest = LAYER_MANIFEST) {
return Object.freeze({
label: t('Experimental Navara engine'),
detail: names.length
? `Data layers: ${names.join(', ')}`
: 'No registered data layers',
? t('Data layers: {layers}', {
layers: names.map(localizeLayerName).join(', '),
})
: t('No registered data layers'),
});
}
@@ -101,7 +104,7 @@ export async function createNavaraScene({
/**
* The Navara tool backend (src/app/tools.js) of one Navara scene: where the
* annotation board and Draw draw (on the engine and Navara's view; null
* annotation board draws (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.
@@ -115,8 +118,6 @@ export function navaraToolBackend({ engine }) {
drawn
? { engine, renderer: createNavaraAnnotationRenderer(engine, views) }
: null,
// The session's tool on the engine's input and preview.
draw: () => ({ engine }),
recentImagery: () => ({ engine }),
attach({ styleManager }, { signal, defer }) {
void styleManager.initialRestorePromise?.then(() => {
+4 -5
View File
@@ -272,7 +272,6 @@ function cesiumToolBackend({ engine, tileset }) {
viewer,
startupChrome: {},
board: () => ({ viewer, engine, tileset }),
draw: () => ({ engine }),
// The tileset lets the layer drape while the globe is hidden.
recentImagery(layer) {
layer.attachTileset(tileset);
@@ -353,7 +352,7 @@ async function attachEngineTools(
{ layerSession, placeSearch, operations },
defer,
);
attachDraw(backend.draw, { layerSession, annotations, styleManager }, defer);
attachDraw(engine, { layerSession, annotations, styleManager }, defer);
// Import draws through the globe contract, the same on every engine.
attachGeoImport(
{ engine },
@@ -483,13 +482,13 @@ function attachBoard(
}
/**
* UTILITIES ▸ Draw on this globe (`drawScene()`: its input and preview):
* UTILITIES ▸ Draw on this globe (its engine input and preview):
* the tool claims the globe's pointer while a sketch is open, so a switch
* ends an open sketch and keeps the chosen shape and colour. Returns the
* tool (null on a page without it or without the board).
*/
function attachDraw(
drawScene,
engine,
{ layerSession, annotations, styleManager },
defer,
) {
@@ -500,7 +499,7 @@ function attachDraw(
styleManager,
defer,
});
drawTool?.attachScene({ ...drawScene(), navigation: styleManager });
drawTool?.attachScene({ engine, navigation: styleManager });
defer(() => drawTool?.detachScene());
return drawTool;
}
+6 -2
View File
@@ -410,6 +410,7 @@ export class CelestialRing {
viewer,
{ enabled = true, onAutoDisable = null, store, engine = null } = {},
) {
this._engine = engine ?? (viewer?.kind ? viewer : null);
this._engineView = viewer?.kind ? createCelestialView(viewer) : null;
this.viewer = this._engineView?.view ?? viewer;
this._projector = engine?.projector ?? null;
@@ -603,7 +604,7 @@ export class CelestialRing {
this._focusInProgress = false;
this.viewer.scene.requestRender?.();
};
flyCelestialViewer(this.viewer, {
const flight = {
destination: geo.cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
@@ -620,7 +621,10 @@ export class CelestialRing {
finishFocus();
if (!this.isGlobeFullyVisible()) this._autoDisable();
},
});
};
if (this._engine)
this._engine.camera.flyTo({ ...flight, easing: 'cubicInOut' });
else flyCelestialViewer(this.viewer, flight);
return true;
}
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CelestialRing } from './celestialRing.js';
test('full globe focus uses the engine without a Cesium global', () => {
let flight;
const engine = {
camera: {
flyTo: (options) => {
flight = options;
},
},
};
const ring = Object.assign(Object.create(CelestialRing.prototype), {
_engine: engine,
viewer: {
scene: {
canvas: { clientWidth: 1200, clientHeight: 800 },
requestRender() {},
},
camera: {
positionCartographic: { longitude: 0, latitude: 0 },
heading: 0,
frustum: { fovy: Math.PI / 3 },
},
},
});
assert.equal(globalThis.Cesium, undefined);
assert.equal(ring.focusFullGlobe(), true);
assert.equal(flight.easing, 'cubicInOut');
assert.equal(ring._focusInProgress, true);
flight.complete();
assert.equal(ring._focusInProgress, false);
});
+1 -3
View File
@@ -662,9 +662,7 @@ class CockpitCloudEffectsController {
}
/**
* Free the GPU objects and the canvas's WebGL context. The application is
* rebuilt on every globe switch, and browsers cap live contexts, so a
* destroyed controller must not leave its context for garbage collection.
* Free GPU objects and the canvas's WebGL context: browsers cap live contexts.
*/
releaseRenderer() {
const gl = this.gl;
+8 -10
View File
@@ -1,6 +1,6 @@
// 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
// 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
@@ -36,12 +36,9 @@ export function cyberSonarEngineState(
};
}
/** Sprite and point instances the engine draws (models keep their colours). */
/** Every mover instance the engine holds, on screen or not. */
const contactCount = (engine) =>
engine.movers
.list()
.filter((set) => set.kind !== 'model')
.reduce((count, set) => count + set.count, 0);
engine.movers.list().reduce((count, set) => count + set.count, 0);
const UNSUPPORTED_MESSAGE =
'Contact GPU unavailable. Native contacts remain visible.';
@@ -85,12 +82,13 @@ export function createCyberSonarEngineScene(
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).
// Contacts the sweep passes over on screen, as the engine's treatment
// reports them (Navara's movers in view; Cesium's tinted models and
// visible native batches); an engine without a report, all its movers.
const contactsShown = () => {
if (contactCount(engine)) return true;
const drawn = diagnostics();
return Boolean(drawn?.contacts || drawn?.gpu?.visibleCommands);
if (!drawn) return contactCount(engine) > 0;
return Boolean(drawn.contacts || drawn.gpu?.visibleCommands);
};
const removePostRender = engine.frames.postRender.addEventListener(() => {
// A treatment lost while the scene is parked (Sonar OFF, reduced
+33
View File
@@ -166,3 +166,36 @@ test("an engine's own contacts keep the sweep drawing, and a lost treatment wake
dispose();
assert.equal(root.dataset.cyberSonarGpu, undefined);
}));
test('contacts held off screen leave the sweep idle', () =>
withCyberPage(async ({ root }) => {
const engine = createFakeEngine({ kind: 'navara', contactSonar: true });
// Navara's report (createNavaraEngine.js): the movers in view.
const report = { supported: true, contacts: 0 };
engine.contactSonarDiagnostics = () => report;
const timers = new Map();
let nextTimer = 1;
const dispose = createCyberSonarEngineScene(engine, null, {
root,
setTimer: (callback, ms) => {
timers.set(nextTimer, [callback, ms]);
return nextTimer++;
},
clearTimer: (id) => timers.delete(id),
});
const set = engine.movers.createSet({
id: 'qa-contacts',
kind: 'point',
capacity: 1,
point: { sizePx: 8 },
});
set.set(0, { position: { x: 6378137, y: 0, z: 0 } });
set.setCount(1);
set.commit();
engine.fake.frame();
assert.equal(timers.size, 0, 'a contact out of view draws nothing');
report.contacts = 1;
engine.fake.frame();
assert.equal(timers.size, 1, 'one in view keeps the sweep moving');
dispose();
}));
+48 -12
View File
@@ -44,6 +44,8 @@ const SCENE_REVEAL_RENDER_HOLD = 'bhote-koshi-scene-beat-reveal';
const SCENE_PATH_RENDER_HOLD = 'bhote-koshi-scene-path-travel';
const SCENE_MEDIA_MIN_PLAYBACK_SECONDS = 6;
const SCENE_MEDIA_EXIT_SECONDS = 0.65;
// A camera this near an evidence anchor has the valley's terrain loaded.
const EVIDENCE_REFINE_M = 25_000;
const BHOTE_EVENT_ACCENT = WORLD_OVERLAY_STYLE.accent;
const FLOOD_COLOR = () =>
Cesium.Color.fromCssColorString('#d8954e').withAlpha(0.68);
@@ -1050,6 +1052,9 @@ export function createBhoteKoshiEventLayer({
let _evidenceTimeline = [];
let _evidenceAnchors = [];
let _evidenceFrameSlots = [];
let _evidenceEntries = [];
// Evidence anchors at the pack's elevations (no terrain sample).
const _fallbackAnchors = new WeakSet();
let _evidenceVideo = null;
let _evidenceVideoIndex = -1;
let _evidenceVideoReady = false;
@@ -1198,14 +1203,17 @@ export function createBhoteKoshiEventLayer({
: [];
let sampled = null;
try {
sampled =
_engine && !_viewer
? observations.map(({ lon, lat }) => ({
height: _engine.surface.terrainHeight(
Cesium.Cartographic.fromDegrees(lon, lat),
),
}))
: await terrainSampler(_viewer, observations, { signal });
if (_engine && !_viewer) {
const revision = _engine.maps.terrainRevision();
const heights = await _engine.surface.sampleTerrainMostDetailed(
observations.map(({ lon, lat }) =>
Cesium.Cartographic.fromDegrees(lon, lat),
),
);
// Heights of a terrain replaced meanwhile are not the one shown.
if (_engine.maps.terrainRevision() === revision)
sampled = heights.map((height) => ({ height }));
} else sampled = await terrainSampler(_viewer, observations, { signal });
} catch (error) {
if (error?.name === 'AbortError') throw error;
console.warn(
@@ -1217,14 +1225,35 @@ export function createBhoteKoshiEventLayer({
return observations.map((observation, index) => {
const sampledHeight = Number(sampled?.[index]?.height);
const fallbackHeight = Number(observation.fallbackElevationM) || 0;
return Cesium.Cartesian3.fromDegrees(
const anchor = Cesium.Cartesian3.fromDegrees(
observation.lon,
observation.lat,
(Number.isFinite(sampledHeight) ? sampledHeight : fallbackHeight) + 18,
);
if (!Number.isFinite(sampledHeight)) _fallbackAnchors.add(anchor);
return anchor;
});
}
// An anchor at the pack's elevation, on Navara, moves onto the terrain
// loaded under a near camera (the valley's, not an overview tile's), in
// place, so the card's cull position follows.
function refineEvidenceAnchor(index) {
const anchor = _evidenceAnchors[index];
if (!anchor || _viewer || !_engine || !_fallbackAnchors.has(anchor))
return anchor || null;
const live = _engine.camera.livePosition;
if (!live || Cesium.Cartesian3.distance(live, anchor) > EVIDENCE_REFINE_M)
return anchor;
const { lon, lat } = _evidenceTimeline[index]?.observation ?? {};
const height = Number.isFinite(lon)
? _engine.surface.terrainHeight(Cesium.Cartographic.fromDegrees(lon, lat))
: undefined;
if (Number.isFinite(height))
Cesium.Cartesian3.fromDegrees(lon, lat, height + 18, undefined, anchor);
return anchor;
}
function evidencePositionGetter(index, breadcrumb = false) {
return () => {
if (!_enabled) return null;
@@ -1245,13 +1274,13 @@ export function createBhoteKoshiEventLayer({
_sceneMediaPlaybackCompletedBeatId
)
return null;
return _evidenceAnchors[index] || null;
return refineEvidenceAnchor(index);
}
const activeIndex = activeEvidenceIndex(_progress, _evidenceTimeline);
if (breadcrumb ? index >= activeIndex : index !== activeIndex)
return null;
if (!breadcrumb && index === _embeddedEvidenceIndex) return null;
return _evidenceAnchors[index] || null;
return refineEvidenceAnchor(index);
};
}
@@ -2448,7 +2477,12 @@ export function createBhoteKoshiEventLayer({
placement: 'above',
});
}
overlayHost.setEntries(BHOTE_KOSHI_OVERLAY_SOURCE_ID, entries, {
_evidenceEntries = entries;
restoreEvidenceEntries();
}
function restoreEvidenceEntries() {
overlayHost.setEntries(BHOTE_KOSHI_OVERLAY_SOURCE_ID, _evidenceEntries, {
cohortLimit: 16,
collisionCapacity: 12,
moving: false,
@@ -3557,6 +3591,7 @@ export function createBhoteKoshiEventLayer({
return;
}
createSplitControl(_event);
restoreEvidenceEntries();
syncPresentationMode();
attachCameraInputRelease();
attachEvidenceCardActivation();
@@ -3739,6 +3774,7 @@ export function createBhoteKoshiEventLayer({
_corridorPositionCache = { visibleCount: -1, positions: [] };
_evidenceTimeline = [];
_evidenceAnchors = [];
_evidenceEntries = [];
_cinematicKeyframes = [];
for (const slot of _evidenceFrameSlots) {
closeFrame(slot.frame);
+179
View File
@@ -3133,3 +3133,182 @@ test('Bhote Koshi stops waiting for a stalled map switch when its scene is relea
globalThis.window = originalWindow;
}
});
test('Bhote Koshi republishes its evidence cards to the incoming engine overlay', async () => {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
installEventDom();
globalThis.window = { setTimeout: () => 1, clearTimeout() {} };
const first = eventViewer();
const second = eventViewer();
const engineB = {
id: 'B',
host: { canvas: second.scene.canvas },
camera: second.camera,
};
const hosts = new Map();
const hostFor = (engine) => {
const key = engine ?? 'A';
if (!hosts.has(key))
hosts.set(key, {
entries: new Map(),
setEntries(id, e) {
this.entries.set(id, e);
},
setVisible() {},
clearSource(id) {
this.entries.delete(id);
},
hitTest() {
return null;
},
});
return hosts.get(key);
};
const layer = createBhoteKoshiEventLayer({
eventLoader: async () => event,
imageLoader: async (url) => ({ url }),
embeddedMediaFactory: () => ({
show() {},
hide() {},
pause() {},
destroy() {},
}),
overlayHost: hostFor,
renderHost: { request() {}, hold() {}, release() {} },
});
layer.attachMapStackController({
getActiveId: () => 'esri-imagery',
getSwitchGeneration: () => 1,
async setStack() {},
});
const manager = new LayerLifecycle(first);
manager.register(layer);
try {
assert.equal(
await manager.setEnabled(layer.id, true, { origin: 'local-restore' }),
true,
);
const a = hostFor(null);
assert.ok(
[...a.entries.values()].some((e) => e.length > 0),
'first engine got the cards',
);
await layer.detachRenderer();
await layer.attachRenderer(second, { engine: engineB });
const b = hostFor(engineB);
assert.ok(
[...b.entries.values()].some((e) => e.length > 0),
'incoming engine overlay should get the cards',
);
} finally {
await manager.destroyLayer(layer.id);
globalThis.document = originalDocument;
globalThis.window = originalWindow;
}
});
// The event on a Navara engine double: its surface samples the terrain at
// its finest level (or fails to), and its loaded terrain answers `terrain`.
async function navaraEvidenceCard({ sample }) {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
installEventDom();
globalThis.window = { setTimeout: () => 1, clearTimeout() {} };
const entries = [];
const engine = {
terrain: 3500,
host: { canvas: eventViewer().scene.canvas },
surface: {
terrainHeight: () => engine.terrain,
sampleTerrainMostDetailed: sample,
},
maps: { terrainRevision: () => 1 },
camera: { livePosition: Cesium.Cartesian3.fromDegrees(0, 0, 1e7) },
};
const layer = buildEventLayer({
eventLoader: async () => event,
imageLoader: async (url) => ({ url }),
createNativeRenderer: () => ({
loadImages: async () => {},
draw: () => ({ destroy() {} }),
setVisible() {},
setSplit() {},
clear() {},
destroy() {},
}),
embeddedMediaFactory: () => ({
show() {},
hide() {},
pause() {},
destroy() {},
}),
overlayHost: {
setEntries: (_id, value) => entries.push(...value),
setVisible() {},
clearSource() {},
},
renderHost: { request() {}, hold() {}, release() {} },
});
await layer.init(null, { engine });
await layer.enable(null, { origin: 'local-restore' });
const [first] = event.evidenceSpine;
const card = entries.find(
(entry) => entry.id === `evidence-card-${first.id}`,
);
return {
engine,
layer,
card,
first,
height: () => Cesium.Cartographic.fromCartesian(card.cullPosition).height,
};
}
test('Navara evidence anchors stand on the most detailed terrain', async () => {
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
const { layer, height } = await navaraEvidenceCard({
sample: async (places) => places.map(() => 1234),
});
try {
assert.ok(Math.abs(height() - 1252) < 0.01, `${height()}`);
} finally {
await layer.disable();
await layer.destroy();
globalThis.document = originalDocument;
globalThis.window = originalWindow;
}
});
test('Navara evidence anchors without a terrain sample follow the terrain near the camera', async () => {
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
const { engine, layer, card, first, height } = await navaraEvidenceCard({
sample: async () => {
throw new Error('no terrain');
},
});
try {
const pack = first.fallbackElevationM + 18;
assert.ok(Math.abs(height() - pack) < 0.01, 'the pack elevation');
card.position();
assert.ok(Math.abs(height() - pack) < 0.01, 'a far camera keeps it');
engine.camera.livePosition = Cesium.Cartesian3.fromDegrees(
first.lon,
first.lat,
6000,
);
engine.terrain = 2000;
assert.ok(card.position(), 'the first card is shown');
assert.ok(
Math.abs(height() - 2018) < 0.01,
'a near camera reads the terrain',
);
} finally {
await layer.disable();
await layer.destroy();
globalThis.document = originalDocument;
globalThis.window = originalWindow;
}
});
+27
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { LayerLifecycle } from './lifecycle.js';
import { cancelCameraArrival, ownCameraArrival } from './cameraArrival.js';
test('an arrival owned on the engine yields to a cancel on the engine', () => {
@@ -33,3 +34,29 @@ test('a newer arrival replaces the older one and release is idempotent', () => {
cancelCameraArrival(null);
assert.deepEqual(seen, ['old', 'new']);
});
test('disabling a layer cancels an engine arrival without a native viewer', async () => {
const engine = createFakeEngine();
const manager = new LayerLifecycle(null, { engine });
manager.register({
id: 'arrival',
name: 'Arrival',
init() {},
enable() {},
disable() {},
update() {},
updateInterval: -1,
});
let cancelled = 0;
const release = ownCameraArrival(engine, () => {
cancelled++;
});
try {
await manager.setEnabled('arrival', false);
assert.equal(cancelled, 1);
} finally {
release();
await manager.destroyLayer('arrival');
engine.destroy();
}
});
+35
View File
@@ -0,0 +1,35 @@
// A file whose only geometry is degenerate (a one-point line, a two-point
// ring) draws nothing; main refused it with "That file has nothing to draw.".
import test from 'node:test';
import assert from 'node:assert/strict';
import { loadGeoFile, GeoImportError } from './geoFileImport.js';
const geojson = (geometry) => {
const blob = new Blob([
JSON.stringify({ type: 'Feature', properties: {}, geometry }),
]);
blob.name = 'degenerate.geojson';
return blob;
};
for (const geometry of [
{ type: 'LineString', coordinates: [[1, 2]] },
{
type: 'Polygon',
coordinates: [
[
[1, 2],
[2, 3],
],
],
},
]) {
test(`a ${geometry.type} with nothing to draw is refused`, async () => {
await assert.rejects(
loadGeoFile(geojson(geometry)),
(error) =>
error instanceof GeoImportError &&
/nothing to draw/.test(error.message),
);
});
}
+234 -45
View File
@@ -1,9 +1,7 @@
// 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);
// - polygons: ground fills, or meshes and outlines at the vertices' 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
@@ -11,13 +9,16 @@
// 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.
// - ground overlays: one image imagery layer each over its rectangle, its
// image tinted and turned on a canvas when the KML says so, an absolute
// one raised to its altitude; a plain colour is a ground fill.
// Nothing is pickable: an imported file has no card. Ground heights under
// the points come from engine.surface, sampled again by refreshGround():
// the terrain where the globe is drawn, else (3D tiles) the scene drawn
// under a low camera, never an imported point (as Cesium's import kept its
// pins out of height queries).
import earcut from 'earcut';
import * as geo from '../geo/index.js';
import { normalizeGroundItem } from '../globe/ground.js';
import { composeIcon } from './geoImportPins.js';
@@ -34,6 +35,10 @@ const HORIZON_LIFT_M = 2000;
// drawn now; higher up the terrain answers (Cesium's 250 km).
const CLAMP_TILES_BELOW_M = 250_000;
const BATCH = 256;
// Canvas pixels every imported icon look shares (64 MiB of RGBA).
const ICON_PIXEL_BUDGET = 16 * 1024 * 1024;
// The longest side of a tinted or turned ground overlay's canvas.
const MAX_OVERLAY_SIDE = 4096;
let serial = 0;
// Each engine's imported point sets, left out of each other's ground.
const pointSets = new WeakMap();
@@ -60,6 +65,103 @@ function checked(kind, item) {
}
}
// A ground overlay's box with east past west across 180°, and how a corner
// turns by its rotation: KML's degrees counter-clockwise about the centre,
// in longitude and latitude (Cesium's rectangle rotation).
function overlayBox({ rectangle, rotation = 0 }) {
const { west, south, north } = rectangle;
const east = rectangle.east < west ? rectangle.east + 360 : rectangle.east;
const box = { west, south, east, north };
const center = [(west + east) / 2, (south + north) / 2];
const angle = (rotation * Math.PI) / 180;
const [cos, sin] = [Math.cos(angle), Math.sin(angle)];
const turn = ([lon, lat]) => {
const [x, y] = [lon - center[0], lat - center[1]];
return [center[0] + x * cos - y * sin, center[1] + x * sin + y * cos];
};
return { box, center, angle, turn };
}
// What a ground overlay's image layer draws: its image tinted by the
// overlay's colour and turned by its rotation onto the box around the
// turned image (transparent outside it). Null when that box passes a pole.
async function overlayImage(overlay, loadImage, createCanvas) {
const tint = overlay.color.slice(0, 3);
const { box, center, angle } = overlayBox(overlay);
if (tint.every((value) => value === 1) && !angle)
return { url: overlay.url, rectangle: overlay.rectangle };
const [width, height] = [box.east - box.west, box.north - box.south];
const [cos, sin] = [Math.abs(Math.cos(angle)), Math.abs(Math.sin(angle))];
const [spanX, spanY] = [
width * cos + height * sin,
width * sin + height * cos,
];
const rectangle = {
west: center[0] - spanX / 2,
south: center[1] - spanY / 2,
east: center[0] + spanX / 2,
north: center[1] + spanY / 2,
};
if (rectangle.south < -90 || rectangle.north > 90 || spanX >= 360)
return null;
let image;
try {
image = await loadImage(overlay.url);
} catch {
// An image that does not decode draws nothing, as on the plain path.
return { url: overlay.url, rectangle: overlay.rectangle };
}
const natural = [
image.naturalWidth || image.width,
image.naturalHeight || image.height,
];
// Pixels per degree: one scale on both axes when turned, so the image
// turns in degrees as Cesium's does.
let [scaleX, scaleY] = [natural[0] / width, natural[1] / height];
if (angle) scaleX = scaleY = Math.max(scaleX, scaleY);
const fit = Math.min(
1,
MAX_OVERLAY_SIDE / (spanX * scaleX),
MAX_OVERLAY_SIDE / (spanY * scaleY),
);
[scaleX, scaleY] = [scaleX * fit, scaleY * fit];
const canvas = createCanvas();
canvas.width = Math.max(1, Math.round(spanX * scaleX));
canvas.height = Math.max(1, Math.round(spanY * scaleY));
const context = canvas.getContext('2d');
context.translate(canvas.width / 2, canvas.height / 2);
// The canvas's y runs south: a counter-clockwise turn is a negative one.
context.rotate(-angle);
context.drawImage(
image,
(-width * scaleX) / 2,
(-height * scaleY) / 2,
width * scaleX,
height * scaleY,
);
if (tint.some((value) => value !== 1)) {
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
for (let i = 0; i < pixels.data.length; i += 4)
for (let channel = 0; channel < 3; channel++)
pixels.data[i + channel] *= tint[channel];
context.putImageData(pixels, 0, 0);
}
const wrap = (lon) => (lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon);
return {
image: canvas,
rectangle: {
...rectangle,
west: wrap(rectangle.west),
east: wrap(rectangle.east),
},
};
}
// KML headings are from north (Cesium's billboard on the Earth's axis):
// the sprite turns on screen with the camera's heading.
const turnsWithCamera = (look) => look.pins.some((pin) => pin.rotation);
const onScreen = (pin, heading) => (pin.rotation ? pin.rotation + heading : 0);
const flat = (positions) => positions.map(([lon, lat]) => [lon, lat]);
const cartesian = ([lon, lat, height = 0]) =>
geo.cartesian3.fromDegrees(lon, lat, height);
@@ -110,6 +212,9 @@ export async function drawImportedFile(
let removeCull = null;
const groundSets = [];
const lines = [];
const meshes = [];
const pins = [];
const kinds = { ...records.undrawn };
const layers = [];
const looks = new Map();
let drawn = 0;
@@ -120,12 +225,14 @@ export async function drawImportedFile(
removeCull = null;
for (const set of groundSets.splice(0)) set.destroy();
for (const line of lines.splice(0)) line.destroy();
for (const mesh of meshes.splice(0)) mesh.destroy();
for (const layer of layers.splice(0)) layer.destroy();
for (const look of looks.values()) {
pointSetsOf(engine).delete(look.set);
look.set?.destroy();
}
looks.clear();
pins.length = 0;
}
try {
@@ -133,7 +240,34 @@ export async function drawImportedFile(
const fills = [];
for (const polygon of records.polygons) {
let any = false;
if (polygon.fill) {
if (polygon.fill && polygon.raised) {
const vertices = polygon.rings.flat();
const origin = vertices[0]?.[0] ?? 0;
const xy = vertices.flatMap(([lon, lat]) => [
origin + ((((lon - origin + 180) % 360) + 360) % 360) - 180,
lat,
]);
let count = 0;
const holes = polygon.rings
.slice(0, -1)
.map((ring) => (count += ring.length));
const indices = earcut(xy, holes);
if (indices.length) {
meshes.push(
engine.meshes.createMesh({
id: `${prefix}-fill-${meshes.length}`,
positions: vertices.flatMap((vertex) => {
const { x, y, z } = cartesian(vertex);
return [x, y, z];
}),
indices,
color: polygon.fill,
show: shown,
}),
);
any = true;
}
} else if (polygon.fill) {
const item = checked('polygon', {
rings: polygon.rings.map(flat),
fill: polygon.fill,
@@ -160,34 +294,47 @@ export async function drawImportedFile(
if (any) drawn++;
}
for (const overlay of records.overlays) {
// Cesium raises only an absolute overlay, to its altitude.
const raise =
overlay.altitudeMode === 'absolute' && overlay.altitude > 0
? overlay.altitude
: undefined;
if (overlay.url) {
const prepared = await overlayImage(overlay, loadImage, createCanvas);
if (!prepared) {
kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1;
continue;
}
layers.push(
engine.imagery.createLayer({
id: `${prefix}-overlay-${layers.length}`,
source: {
kind: 'image',
url: overlay.url,
rectangle: overlay.rectangle,
},
source: { kind: 'image', ...prepared },
// Cesium's transparent image material: at most 0.99.
alpha: Math.min(0.99, overlay.color[3]),
show: shown,
order: OVERLAY_ORDER,
...(raise ? { raise } : {}),
}),
);
drawn++;
continue;
}
// A plain colour draws as a ground fill.
if (raise) {
kinds.raisedOverlay = (kinds.raisedOverlay ?? 0) + 1;
continue;
}
const { west, south, east, north } = overlay.rectangle;
const item = checked('cell', { west, south, east, north });
if (!item) continue;
const { box, turn } = overlayBox(overlay);
const rings = [
[
[west, south],
[east < west ? east + 360 : east, south],
[east < west ? east + 360 : east, north],
[west, north],
],
[box.west, box.south],
[box.east, box.south],
[box.east, box.north],
[box.west, box.north],
].map(turn),
];
fills.push({ rings, fill: overlay.color });
drawn++;
@@ -261,6 +408,11 @@ export async function drawImportedFile(
rotation: point.icon.rotation ?? 0,
});
}
pins.push(...[...looks.values()].flatMap((look) => look.pins));
const maxSide = Math.min(
512,
Math.floor(Math.sqrt(ICON_PIXEL_BUDGET / Math.max(1, looks.size))),
);
await Promise.all(
[...looks.values()].map(async (look) => {
const url = look.icon.image?.url;
@@ -279,6 +431,7 @@ export async function drawImportedFile(
image,
createCanvas,
ratio: pixelRatio,
maxSide,
});
look.set = engine.movers.createSet({
id: `${prefix}-pins-${look.depth}-${looks.size}-${++serial}`,
@@ -299,7 +452,11 @@ export async function drawImportedFile(
}),
);
if (destroyed) throw new Error('The imported file was removed');
settle(heightContext());
for (let from = 0; from < pins.length; from += BATCH) {
settle(heightContext(), from, from + BATCH);
if (from + BATCH < pins.length)
await new Promise((resolve) => setTimeout(resolve, 0));
}
for (const look of looks.values()) writePins(look);
syncCull();
} catch (error) {
@@ -316,11 +473,30 @@ export async function drawImportedFile(
(engine.imagery?.surface?.() ?? 'globe') !== 'globe' &&
surface.clampToHeightSupported &&
engine.camera.height() < CLAMP_TILES_BELOW_M;
return { surface, tiles, exclude: [...pointSetsOf(engine)] };
return {
surface,
tiles,
exclude: [...pointSetsOf(engine)],
size: tiles ? engine.host.size() : null,
};
}
// Whether a position is in the window now: clamping to 3D tiles reads
// the tiles drawn, and a point elsewhere would only cost the query.
function inWindow(position, { width, height }) {
if (!engine.projector.horizonVisible(position)) return false;
const screen = engine.projector.worldToWindow(position);
return (
Boolean(screen) &&
screen.x >= 0 &&
screen.y >= 0 &&
screen.x <= width &&
screen.y <= height
);
}
// The ground under a pin, falling back to the last known one.
function groundUnder(pin, { surface, tiles, exclude }) {
function groundUnder(pin, { surface, tiles, exclude, size }) {
const at = geo.cartographic.fromDegrees(pin.lon, pin.lat, 0);
const terrain = surface.terrainHeightSupported
? surface.terrainHeight(at)
@@ -331,7 +507,10 @@ export async function drawImportedFile(
? Math.max(0, terrain)
: terrain
: (pin.ground ?? 0);
if (tiles) {
if (
tiles &&
inWindow(geo.cartesian3.fromDegrees(pin.lon, pin.lat, ground), size)
) {
const top = surface.clampToHeight(
geo.cartesian3.fromDegrees(pin.lon, pin.lat, Math.max(0, ground)),
exclude,
@@ -342,33 +521,33 @@ export async function drawImportedFile(
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 settle(context, from = 0, to = pins.length) {
for (let i = from; i < Math.min(to, pins.length); i++) {
const pin = pins[i];
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';
const heading = turnsWithCamera(look) ? engine.camera.pose().heading : 0;
pins.forEach((pin, index) => {
if (culled) pin.show = engine.projector.horizonVisible(pin.horizon);
pin.drawnRotation = onScreen(pin, heading);
set.set(index, {
position: pin.position,
cell: 'icon',
color: pin.color,
rotation: pin.rotation,
rotation: pin.drawnRotation,
show: pin.show,
});
});
@@ -377,14 +556,18 @@ export async function drawImportedFile(
set.commit();
}
// Hides the ground points beyond the horizon; true when any show changed.
function cull(look) {
// Hides the ground points beyond the horizon and turns the headed ones
// with the camera; true when any changed.
function cull(look, heading) {
let changed = false;
look.pins.forEach((pin, index) => {
const show = engine.projector.horizonVisible(pin.horizon);
if (show === pin.show) return;
const show =
look.depth !== 'always' || engine.projector.horizonVisible(pin.horizon);
const rotation = onScreen(pin, heading);
if (show === pin.show && rotation === pin.drawnRotation) return;
pin.show = show;
look.set.set(index, { show });
pin.drawnRotation = rotation;
look.set.set(index, { show, rotation });
changed = true;
});
if (changed) look.set.commit();
@@ -392,33 +575,39 @@ export async function drawImportedFile(
}
function syncCull() {
const culled = [...looks.values()].filter(
(look) => look.set && look.depth === 'always',
(look) => look.set && (look.depth === 'always' || turnsWithCamera(look)),
);
if (!culled.length || !shown) {
removeCull?.();
removeCull = null;
return;
}
const turning = culled.some(turnsWithCamera);
const lastCamera = geo.cartesian3.create(Number.NaN, 0, 0);
let lastHeading = Number.NaN;
removeCull ??= engine.frames.preRender.addEventListener(() => {
const live = engine.camera.livePosition;
if (geo.cartesian3.equals(live, lastCamera)) return;
const heading = turning ? engine.camera.pose().heading : 0;
if (geo.cartesian3.equals(live, lastCamera) && heading === lastHeading)
return;
lastHeading = heading;
geo.cartesian3.clone(live, lastCamera);
let changed = false;
for (const look of culled) changed = cull(look) || changed;
for (const look of culled) changed = cull(look, heading) || changed;
if (changed) request();
});
}
const undrawn = Object.values(records.undrawn).reduce((n, c) => n + c, 0);
const undrawn = Object.values(kinds).reduce((n, c) => n + c, 0);
return {
report: { drawn, undrawn, kinds: { ...records.undrawn } },
report: { drawn, undrawn, kinds },
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 mesh of meshes) mesh.show(shown);
for (const layer of layers) layer.show(shown);
for (const look of looks.values()) {
look.set?.setCount(shown ? look.count : 0);
@@ -444,7 +633,7 @@ export async function drawImportedFile(
},
/** 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);
const total = pins.length;
for (let from = 0; from < total; from += BATCH) {
signal?.throwIfAborted();
if (destroyed) return;
+222 -3
View File
@@ -4,7 +4,7 @@ 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';
import { emptyRecords, geoJsonRecords } from './geoImportRecords.js';
// A 2D context that only records what draws.
const createCanvas = () => ({
@@ -191,6 +191,8 @@ for (const make of GROUND_HARNESSES) {
viewer.scene.clampToHeightSupported = true;
} else engine.imagery.surface = () => 'tiles';
engine.camera.height = () => 1000;
engine.projector.worldToWindow = () => ({ x: 1, y: 1 });
engine.projector.horizonVisible = () => true;
const excluded = [];
engine.surface.clampToHeight = (position, exclude) => {
excluded.push(exclude);
@@ -270,8 +272,9 @@ for (const make of GROUND_HARNESSES) {
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.equal(fill.items.length, 1);
assert.equal(engine.meshes.list().length, 1);
assert.equal(fill.items[0].rings.length, 1);
assert.ok(fill.items[0].rings.flat().every((p) => p.length === 2));
assert.equal(fill.shown, false);
assert.equal(engine.lines.list().length, 2);
@@ -339,6 +342,222 @@ for (const make of GROUND_HARNESSES) {
},
);
displayTest(
'KML headings follow north as the camera rotates',
async ({ engine, pins }) => {
let heading = 0;
engine.camera.pose = () => ({ heading });
const records = geoJsonRecords(
{ type: 'Point', coordinates: [1, 2] },
'#39d0ff',
);
records.points[0].icon.rotation = -Math.PI / 4;
const drawing = await drawImportedFile(engine, records, { createCanvas });
assert.equal(pins[0].items[0].rotation, -Math.PI / 4);
heading = Math.PI / 2;
engine.frames.preRender.raiseEvent();
assert.equal(pins[0].items[0].rotation, Math.PI / 4);
drawing.destroy();
},
);
// A canvas whose context records the transform and holds one pixel.
const recordingCanvas = () => {
const calls = [];
const pixels = { data: new Uint8ClampedArray([128, 64, 32, 128]) };
const context = {
translate: (...args) => calls.push(['translate', ...args]),
rotate: (angle) => calls.push(['rotate', angle]),
drawImage: (_image, ...args) => calls.push(['drawImage', ...args]),
getImageData: () => pixels,
putImageData: (value) => calls.push(['putImageData', value]),
};
return { calls, pixels, canvas: { getContext: () => context } };
};
const overlayLayer = (engine) => {
const layers = [];
const create = engine.imagery.createLayer;
engine.imagery.createLayer = (options) => {
layers.push(options);
return create(options);
};
return layers;
};
displayTest(
'colored overlays multiply image RGB and retain layer alpha',
async ({ engine }) => {
const { calls, pixels, canvas } = recordingCanvas();
const layers = overlayLayer(engine);
const records = emptyRecords();
records.overlays.push({
url: 'blob:overlay',
color: [1, 0, 0, 0.5],
rectangle: { west: 0, south: 0, east: 1, north: 1 },
rotation: 0,
});
const drawing = await drawImportedFile(engine, records, {
createCanvas: () => canvas,
loadImage: async () => ({ width: 32, height: 16 }),
});
assert.equal(layers[0].source.image, canvas);
assert.deepEqual(
{ ...layers[0].source.rectangle },
{ west: 0, south: 0, east: 1, north: 1 },
);
assert.equal(layers[0].alpha, 0.5);
assert.deepEqual([canvas.width, canvas.height], [32, 16]);
assert.ok(
calls.some(
([name, value]) => name === 'putImageData' && value === pixels,
),
);
assert.deepEqual([...pixels.data], [128, 0, 0, 128]);
drawing.destroy();
},
);
displayTest(
'a rotated overlay turns its image counter-clockwise onto the box around it',
async ({ engine }) => {
const { calls, canvas } = recordingCanvas();
const layers = overlayLayer(engine);
const records = emptyRecords();
records.overlays.push({
url: 'blob:overlay',
color: [1, 1, 1, 1],
// Two degrees by one, across 180°, turned a quarter.
rectangle: { west: 179, south: 10, east: -179, north: 11 },
rotation: 90,
});
const drawing = await drawImportedFile(engine, records, {
createCanvas: () => canvas,
loadImage: async () => ({ width: 200, height: 100 }),
});
assert.equal(drawing.report.drawn, 1);
assert.equal(drawing.report.undrawn, 0);
const { rectangle } = layers[0].source;
assert.ok(Math.abs(rectangle.west - 179.5) < 1e-9);
assert.ok(Math.abs(rectangle.east + 179.5) < 1e-9);
assert.ok(Math.abs(rectangle.south - 9.5) < 1e-9);
assert.ok(Math.abs(rectangle.north - 11.5) < 1e-9);
// 100 pixels a degree on both axes: the turned box is 1° by 2°.
assert.deepEqual([canvas.width, canvas.height], [100, 200]);
assert.deepEqual(
calls.find(([name]) => name === 'rotate'),
['rotate', -Math.PI / 2],
);
assert.deepEqual(
calls.find(([name]) => name === 'drawImage'),
['drawImage', -100, -50, 200, 100],
);
assert.equal(
calls.some(([name]) => name === 'putImageData'),
false,
);
drawing.destroy();
},
);
displayTest(
'a rotated overlay whose box passes a pole is reported undrawn',
async ({ engine }) => {
const records = emptyRecords();
records.overlays.push({
url: 'blob:overlay',
color: [1, 1, 1, 1],
rectangle: { west: 0, south: 80, east: 30, north: 89 },
rotation: 45,
});
const drawing = await drawImportedFile(engine, records, { createCanvas });
assert.deepEqual(drawing.report, {
drawn: 0,
undrawn: 1,
kinds: { rotatedOverlay: 1 },
});
assert.equal(engine.imagery.list().length, 0);
drawing.destroy();
},
);
displayTest(
'a rotated plain-colour overlay fills its turned corners',
async ({ engine, set }) => {
const records = emptyRecords();
records.overlays.push({
url: null,
color: [0, 1, 0, 0.5],
rectangle: { west: 0, south: 0, east: 2, north: 2 },
rotation: 90,
});
const drawing = await drawImportedFile(engine, records, { createCanvas });
const fill = set(engine.ground.list()[0].id);
const [ring] = fill.items[0].rings;
const near = (a, b) => a.every((v, i) => Math.abs(v - b[i]) < 1e-9);
assert.ok(near(ring[0], [2, 0]), JSON.stringify(ring));
assert.ok(near(ring[1], [2, 2]), JSON.stringify(ring));
assert.ok(near(ring[2], [0, 2]), JSON.stringify(ring));
drawing.destroy();
},
);
displayTest(
'an absolute overlay is raised to its altitude, a relative one drapes',
async ({ engine }) => {
const layers = overlayLayer(engine);
const records = emptyRecords();
for (const altitudeMode of ['absolute', 'relative'])
records.overlays.push({
url: 'blob:overlay',
altitudeMode,
altitude: 1000,
color: [1, 1, 1, 1],
rectangle: { west: 0, south: 0, east: 1, north: 1 },
rotation: 0,
});
const drawing = await drawImportedFile(engine, records, { createCanvas });
assert.deepEqual(drawing.report, { drawn: 2, undrawn: 0, kinds: {} });
assert.equal(layers[0].raise, 1000);
assert.equal(layers[1].raise, undefined);
drawing.destroy();
},
);
displayTest(
'tile clamps only sample visible pins and yield between batches',
async ({ engine }) => {
engine.imagery.surface = () => 'tiles';
engine.camera.height = () => 1000;
engine.projector.horizonVisible = () => true;
let visible = false;
engine.projector.worldToWindow = () => (visible ? { x: 1, y: 1 } : null);
let clamps = 0;
engine.surface.clampToHeight = (position) => {
clamps++;
return position;
};
const records = geoJsonRecords(
{
type: 'MultiPoint',
coordinates: Array.from({ length: 600 }, () => [1, 2]),
},
'#39d0ff',
);
const drawing = await drawImportedFile(engine, records, { createCanvas });
assert.equal(clamps, 0);
visible = true;
let beforeYield;
const heartbeat = setTimeout(() => {
beforeYield = clamps;
}, 0);
await drawing.refreshGround();
clearTimeout(heartbeat);
assert.equal(beforeYield, 256);
assert.equal(clamps, 600);
drawing.destroy();
},
);
displayTest(
'an aborted ground refresh leaves the existing drawing owned',
async ({ engine }) => {
+97
View File
@@ -0,0 +1,97 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { drawImportedFile } from './geoImportDisplay.js';
import { emptyRecords } from './geoImportRecords.js';
// A globe engine double that records what drawImportedFile asks of it.
function recordingEngine() {
const calls = { groundSets: [], lines: [], layers: [], moverSets: [] };
const handle = (extra = {}) => ({
destroy() {},
show() {},
commit() {},
...extra,
});
const engine = {
calls,
render: { request() {} },
ground: {
createSet(options) {
const rec = { options, items: [] };
calls.groundSets.push(rec);
return handle({ replace: (items) => (rec.items = items) });
},
},
lines: {
createLine(options) {
const rec = { options, positions: null };
calls.lines.push(rec);
return handle({ setPositions: (p) => (rec.positions = p) });
},
},
imagery: {
surface: () => 'globe',
createLayer(options) {
calls.layers.push(options);
return handle();
},
},
movers: {
createSet(options) {
const rec = { options, items: [] };
calls.moverSets.push(rec);
return handle({
set: (i, f) => (rec.items[i] = { ...rec.items[i], ...f }),
setCount() {},
});
},
},
surface: { terrainHeightSupported: false, clampToHeightSupported: false },
camera: { height: () => 1e7, livePosition: { x: 1, y: 0, z: 0 } },
projector: { horizonVisible: () => true },
frames: { preRender: { addEventListener: () => () => {} } },
};
return engine;
}
const createCanvas = () => ({
width: 0,
height: 0,
getContext: () =>
new Proxy({}, { get: (_, key) => (key in {} ? undefined : () => {}) }),
});
const draw = (engine, records, extra = {}) =>
drawImportedFile(engine, records, {
createCanvas,
loadImage: async () => ({ width: 32, height: 32 }),
pixelRatio: 2,
...extra,
});
const box = { west: 10, south: 10, east: 11, north: 11 };
test('icon variants of one file stay within a canvas budget', async () => {
const engine = recordingEngine();
const points = [];
for (let i = 0; i < 2000; i++)
points.push({
position: [1, 2, 0],
altitude: 'ground',
icon: { image: { url: 'blob:icon' }, scale: 10 + i / 1000 },
});
let bytes = 0;
await draw(
engine,
{ ...emptyRecords(), points },
{
createCanvas: () => {
const c = createCanvas();
return new Proxy(c, {
set(target, key, value) {
if (key === 'height') bytes += target.width * value * 4;
target[key] = value;
return true;
},
});
},
},
);
assert.ok(bytes <= 256 * 2 ** 20, `canvas bytes ${bytes / 2 ** 20} MiB`);
});
+2
View File
@@ -380,6 +380,8 @@ function readGroundOverlay(el, records) {
rectangle,
color,
rotation: numberOf(box, 'rotation') ?? 0,
altitude: numberOf(el, 'altitude') ?? 0,
altitudeMode: altitudeOf(el),
});
}
+12
View File
@@ -400,3 +400,15 @@ test('a KML file loads to records with its name, count and outside content remov
assert.equal(loaded.records.points[0].name, 'QA KML pin');
loaded.revoke();
});
test('GroundOverlay retains altitude and altitude mode for drawing diagnostics', () => {
const records = readKmlRecords(
parseXml(
kml(
`<GroundOverlay><altitude>1000</altitude><altitudeMode>absolute</altitudeMode><LatLonBox><west>0</west><east>1</east><south>0</south><north>1</north></LatLonBox></GroundOverlay>`,
),
),
);
assert.equal(records.overlays[0].altitude, 1000);
assert.equal(records.overlays[0].altitudeMode, 'absolute');
});
+5 -2
View File
@@ -87,7 +87,10 @@ export function iconBox(icon, natural) {
* `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 } = {}) {
export function composeIcon(
icon,
{ image, createCanvas, ratio = 1, maxSide = 512 } = {},
) {
const natural = image
? {
width: image.naturalWidth || image.width,
@@ -96,7 +99,7 @@ export function composeIcon(icon, { image, createCanvas, ratio = 1 } = {}) {
: 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 k = Math.min(ratio, maxSide / box.side);
const canvas = createCanvas();
canvas.width = Math.max(1, Math.ceil(box.side * k));
canvas.height = canvas.width;
+112
View File
@@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { drawImportedFile } from './geoImportDisplay.js';
import { emptyRecords } from './geoImportRecords.js';
// A globe engine double that records what drawImportedFile asks of it.
function recordingEngine() {
const calls = {
meshes: [],
groundSets: [],
lines: [],
layers: [],
moverSets: [],
};
const handle = (extra = {}) => ({
destroy() {},
show() {},
commit() {},
...extra,
});
const engine = {
calls,
render: { request() {} },
meshes: {
createMesh(options) {
calls.meshes.push(options);
return handle();
},
},
ground: {
createSet(options) {
const rec = { options, items: [] };
calls.groundSets.push(rec);
return handle({ replace: (items) => (rec.items = items) });
},
},
lines: {
createLine(options) {
const rec = { options, positions: null };
calls.lines.push(rec);
return handle({ setPositions: (p) => (rec.positions = p) });
},
},
imagery: {
surface: () => 'globe',
createLayer(options) {
calls.layers.push(options);
return handle();
},
},
movers: {
createSet(options) {
const rec = { options, items: [] };
calls.moverSets.push(rec);
return handle({
set: (i, f) => (rec.items[i] = { ...rec.items[i], ...f }),
setCount() {},
});
},
},
surface: { terrainHeightSupported: false, clampToHeightSupported: false },
camera: { height: () => 1e7, livePosition: { x: 1, y: 0, z: 0 } },
projector: { horizonVisible: () => true },
frames: { preRender: { addEventListener: () => () => {} } },
};
return engine;
}
const createCanvas = () => ({
width: 0,
height: 0,
getContext: () =>
new Proxy({}, { get: (_, key) => (key in {} ? undefined : () => {}) }),
});
const draw = (engine, records, extra = {}) =>
drawImportedFile(engine, records, {
createCanvas,
loadImage: async () => ({ width: 32, height: 32 }),
pixelRatio: 2,
...extra,
});
const box = { west: 10, south: 10, east: 11, north: 11 };
test('a raised polygon fill keeps its heights (not a ground fill)', async () => {
const engine = recordingEngine();
await draw(engine, {
...emptyRecords(),
polygons: [
{
rings: [
[
[0, 0, 1000],
[1, 0, 1000],
[1, 1, 1000],
],
],
raised: true,
fill: [1, 0, 0, 1],
outline: { color: [1, 1, 1, 1], widthPx: 1 },
},
],
});
const groundFills = engine.calls.groundSets
.filter((s) => s.options.kind === 'polygon')
.flatMap((s) => s.items);
assert.equal(
groundFills.length,
0,
'the 1000 m fill is draped on the ground',
);
assert.equal(engine.calls.meshes.length, 1);
assert.equal(engine.calls.meshes[0].positions[0], 6379137);
assert.equal(engine.calls.meshes[0].indices.length, 3);
});
+11 -3
View File
@@ -16,7 +16,9 @@
// @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.
// engine draws, by kind (`wall`, `extruded`, `latLonQuad`), counted;
// the display adds the overlays it cannot draw (`rotatedOverlay`,
// `raisedOverlay`).
//
// @typedef {object} ImportedPoint
// @property {number[]} position
@@ -62,6 +64,9 @@
// @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).
// @property {number} altitude Metres (KML altitude); Cesium raises an
// absolute overlay to it.
// @property {'ground'|'relative'|'absolute'} altitudeMode
/** Longest plain-text name kept from a file's features. */
const MAX_IMPORT_NAME_LENGTH = 120;
@@ -162,7 +167,7 @@ export function geoJsonRecords(geojson, color) {
origin: 'bottom',
},
});
else if (kind === 'LineString')
else if (kind === 'LineString' && part.length >= 2)
records.lines.push({
name,
positions: part.map(position),
@@ -170,7 +175,10 @@ export function geoJsonRecords(geojson, color) {
color: stroke,
widthPx: GEOJSON_STROKE_PX,
});
else if (kind === 'Polygon' && part[0]?.length)
else if (
kind === 'Polygon' &&
new Set(part[0]?.map(([lon, lat]) => `${lon},${lat}`)).size >= 3
)
records.polygons.push({
name,
rings: part.map((ring) => ring.map(position)),
+31 -19
View File
@@ -404,25 +404,6 @@ export class LayerStateCoordinator {
live,
enabled.has(entry.id),
);
// A same-document engine choice retains operational layer intents.
// The share still owns durable preferences and later history visits.
if (
live &&
this.dataManager.layers.get(entry.id) === live.entry &&
!live.entry.lifecycleUncertain &&
live.entry.enabled === targetEnabled
) {
return {
layerId: entry.id,
targetEnabled,
origin,
phase: 'retained',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: {},
persistenceWrite: false,
succeeded: true,
};
}
const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
for (const trackingKey of Object.values(
@@ -445,6 +426,37 @@ export class LayerStateCoordinator {
succeeded: false,
};
}
// A same-document engine choice retains operational layer intents.
// The share still owns durable preferences and later history visits:
// on a visit, the entry's options that differ from the live layer
// apply; a pick keeps the live layer as it is.
if (
live &&
this.dataManager.layers.get(entry.id) === live.entry &&
!live.entry.lifecycleUncertain &&
live.entry.enabled === targetEnabled
) {
const current = this.dataManager.getLayerParams(entry.id);
const changed =
live.visit === true &&
options &&
Object.entries(options).some(
([key, value]) => !Object.is(current?.[key], value),
);
const succeeded =
!changed ||
this.dataManager.setLayerParams(entry.id, options, { origin });
return {
layerId: entry.id,
targetEnabled,
origin,
phase: 'retained',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: changed && succeeded ? options : {},
persistenceWrite: false,
succeeded,
};
}
// Reserve passive option state before any asynchronous lifecycle work.
// A later explicit params intent then wins on its own lane without
// cancelling or being overwritten by the visibility restore.
+130
View File
@@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DataLayerManager } from './manager.js';
import { LAYER_STATE_REGISTRY, REGISTERED_LAYER_IDS } from './layerState.js';
import { LayerStateCoordinator } from './layerStateCoordinator.js';
let windParamWrites = 0;
function layer(id) {
let params =
id === 'wind'
? { model: 'gfs', overlay: 'none', units: 'km/h', paused: false }
: null;
return {
id,
name: id,
icon: '',
source: 'test',
async init() {
return true;
},
async enable() {
return true;
},
async update() {
return true;
},
async disable() {
return true;
},
attachRenderer() {},
...(params
? {
setParams(next = {}) {
windParamWrites++;
params = { ...params, ...next };
return true;
},
getParams() {
return { ...params };
},
}
: {}),
};
}
function manager() {
const m = new DataLayerManager({});
for (const id of REGISTERED_LAYER_IDS) m.register(layer(id));
m.finalizeRegistrations(LAYER_STATE_REGISTRY);
return m;
}
const storage = () => {
const v = new Map();
return { getItem: (k) => v.get(k) ?? null, setItem: (k, x) => v.set(k, x) };
};
const sink = () => ({ setLayerStateProvider() {}, onLayerStateChange() {} });
const cesiumEntry = {
enabledLayerIds: ['wind'],
options: { wind: { model: 'gfs' } },
};
test('same-engine Back to a gfs entry restores gfs (reference)', async () => {
const m = manager();
const c = new LayerStateCoordinator(m, sink(), { storage: storage() });
await m.setEnabled('wind', true, { origin: 'user' });
m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' });
assert.equal(m.getLayerParams('wind').model, 'ifs');
await c.applyShareLayerState({ shareLayerState: cesiumEntry });
assert.equal(m.getLayerParams('wind').model, 'gfs');
});
const handoffOf = (m) =>
new Map(
[...m.layers].map(([id, entry]) => [
id,
{ entry, enabled: m.isEffectivelyEnabled(id), durable: id === 'wind' },
]),
);
test('cross-engine Back to a gfs entry restores gfs', async () => {
const m = manager();
await m.setEnabled('wind', true, { origin: 'user' });
m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' });
const handoffLayers = new Map(
[...m.layers].map(([id, entry]) => [
id,
{
entry,
enabled: m.isEffectivelyEnabled(id),
durable: id === 'wind',
visit: true,
},
]),
);
const c = new LayerStateCoordinator(m, sink(), { storage: storage() });
await c.start({ shareLayerState: cesiumEntry, handoffLayers });
assert.equal(c.getDurableState().options.wind.model, 'gfs');
assert.equal(
m.getLayerParams('wind').model,
'gfs',
'live wind should follow the visited entry',
);
});
test('a picked engine keeps the live layer options, not the address', async () => {
const m = manager();
await m.setEnabled('wind', true, { origin: 'user' });
m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' });
const writes = windParamWrites;
const c = new LayerStateCoordinator(m, sink(), { storage: storage() });
await c.start({ shareLayerState: cesiumEntry, handoffLayers: handoffOf(m) });
assert.equal(windParamWrites, writes, 'a pick writes no params');
assert.equal(m.getLayerParams('wind').model, 'ifs');
});
test('an engine switch leaves a retained layer whose options match alone', async () => {
const m = manager();
await m.setEnabled('wind', true, { origin: 'user' });
m.setLayerParams('wind', { model: 'gfs' }, { origin: 'user' });
const writes = windParamWrites;
const c = new LayerStateCoordinator(m, sink(), { storage: storage() });
const results = await c.start({
shareLayerState: cesiumEntry,
handoffLayers: handoffOf(m),
});
assert.equal(windParamWrites, writes, 'no params write for equal options');
const wind = results.find((result) => result.layerId === 'wind');
assert.equal(wind.phase, 'retained');
assert.equal(wind.succeeded, true);
assert.deepEqual(wind.appliedOptions, {});
});
+2 -3
View File
@@ -1207,9 +1207,8 @@ export class LayerLifecycle extends LayerSceneBinding {
const entry = this.layers.get(layerId);
if (!entry) return { intentEpoch: null, promise: Promise.resolve() };
const desiredState = Boolean(shouldEnable);
// A layer leaving ends the camera arrival correction on a globe with a
// viewer (Cesium).
if (!desiredState && this.viewer) cancelCameraArrival(this.engine);
// A layer leaving ends its camera arrival correction.
if (!desiredState) cancelCameraArrival(this.engine);
if (entry.destroying) {
return {
intentEpoch: null,
+15 -3
View File
@@ -169,10 +169,17 @@ for (const makeHarness of [fakeHarness, createCesiumMoversEngine]) {
height(native.gevDisplayPosition()) < 100,
'a ready tracked model releases the billboard floor',
);
// While the model owns the visual the card sits on it, not on the
// released billboard floor below it.
const card = { ...publications.at(-1).position() };
assert.deepEqual(
publications.at(-1).position(),
follow.label.position(),
'the card uses the follow label cache',
card,
{ ...native.gevVisualPosition() },
'the card anchors on the tracked model',
);
assert.ok(
height(card) >= 204.5 && height(card) < 217,
'the card stands with the model on the sampled ground',
);
const cached = { ...native.gevDisplayPosition() };
publications.at(-1).position();
@@ -185,6 +192,11 @@ for (const makeHarness of [fakeHarness, createCesiumMoversEngine]) {
await frames();
const zoomedOut = layer.qaDrawn().find((row) => row.trackedModel)?.model;
assert.ok(!zoomedOut?.show, 'zooming out releases the tracked model');
assert.deepEqual(
{ ...publications.at(-1).position() },
{ ...follow.label.position() },
'without a model the card uses the follow label cache',
);
if (layerId === 'flights')
assert.ok(
height(native.gevDisplayPosition()) >= 205.5,
+1
View File
@@ -191,6 +191,7 @@ export function createTrackedReadout(
return typeof model === 'function' ? model() : model;
},
gevDisplayPosition: () => (handle.label ?? label).position(),
gevVisualPosition: () => handle.native?.gevVisualPosition?.(),
};
_labelSubjects.set(handle, subject);
}
@@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createTrackedReadout } from './trackedReadout.js';
test('followed aircraft card anchors to the model the person sees (gevVisualPosition)', () => {
const display = { x: 1, y: 0, z: 0 };
const visual = { x: 1, y: 0, z: 100 };
const native = {
gevTrackedId: 'flight:abc',
gevLabelModel: { title: 'ABC123' },
gevDisplayPosition: () => display,
gevVisualPosition: () => visual,
};
const handle = {
native,
label: {
id: 'flight:abc',
model: () => native.gevLabelModel,
position: () => native.gevDisplayPosition(),
},
};
const listeners = [];
const engine = {
camera: {
following: () => handle,
followChanged: {
addEventListener: (f) => {
listeners.push(f);
return () => {};
},
},
},
};
let entries = null;
const overlay = {
setEntries(_id, e) {
entries = e;
},
setVisible() {},
clearSource() {
entries = null;
},
};
const readout = createTrackedReadout(engine, {
overlay,
events: new EventTarget(),
selectedContext: () => null,
});
readout.init();
assert.ok(entries?.length === 1, 'card published');
assert.deepEqual(entries[0].position(), visual);
});
-24
View File
@@ -51,30 +51,6 @@ export const VESSEL_TYPE_CSS_COLORS = Object.freeze([
...new Set([...TYPE_STYLES, DEFAULT_STYLE].map(({ css }) => css)),
]);
const NUMERIC_TYPE_SPECIALS = {
30: 'FISHING',
31: 'TOWING',
32: 'TOWING',
33: 'DREDGER',
34: 'DIVE OPS',
35: 'MILITARY',
36: 'SAILING',
37: 'PLEASURE',
50: 'PILOT',
51: 'SAR',
52: 'TUG',
53: 'PORT TENDER',
54: 'ANTI-POLLUTION',
55: 'LAW ENFORCE',
58: 'MEDICAL',
};
const NUMERIC_TYPE_FAMILIES = {
4: 'HIGH-SPEED',
6: 'PASSENGER',
7: 'CARGO',
8: 'TANKER',
9: 'OTHER',
};
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
// and tests match on them.
const TYPE_DISPLAY_KEYS = Object.freeze({
-6
View File
@@ -287,12 +287,6 @@ export function getColumn(matrix, index, result) {
/** The inverse of a rotation-plus-translation matrix (no scale or shear). */
export function inverseTransformation(matrix, result) {
// This function is an optimized version of the below 4 lines.
// const rT = Matrix3.transpose(Matrix4.getMatrix3(matrix));
// const rTN = Matrix3.negate(rT);
// const rTT = Matrix3.multiplyByVector(rTN, Matrix4.getTranslation(matrix));
// return Matrix4.fromRotationTranslation(rT, rTT, result);
const matrix0 = matrix[0];
const matrix1 = matrix[1];
const matrix2 = matrix[2];
-2
View File
@@ -99,8 +99,6 @@ export function scaleToGeodeticSurface(
func = x2 * xMultiplier2 + y2 * yMultiplier2 + z2 * zMultiplier2 - 1.0;
// "denominator" here refers to the use of this expression in the velocity and acceleration
// computations in the sections to follow.
denominator =
x2 * xMultiplier3 * oneOverRadiiSquaredX +
y2 * yMultiplier3 * oneOverRadiiSquaredY +
+6
View File
@@ -203,6 +203,12 @@ export function createCesiumCamera(viewer) {
if (!view?.destination)
throw new TypeError('setView needs a destination');
const orientation = cesiumOrientation(view.orientation);
// A view replaces a running flight, as on Navara (Cesium's own tween
// would otherwise overwrite it on the next frame).
if (flight || viewer.scene.tweens?.length > 0) {
cesium.cancelFlight();
flight = null;
}
cesium.setView({
destination: cesiumDestination(view.destination),
orientation,
Loaded 100 of 276 files, more files were not shown because too many files have changed in this diff. Show more