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: 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. - **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. - **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. - **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) | | **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" | | **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) | | **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) | | **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" | | **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" | | **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`, are adapted from `@cesium/engine/Source/Core` (`Math.js`, `Ellipsoid.js`,
`scaleToGeodeticSurface.js`, `Cartographic.js`, `Cartesian2.js`, `scaleToGeodeticSurface.js`, `Cartographic.js`, `Cartesian2.js`,
`Cartesian3.js`, `Matrix3.js`, `Matrix4.js`, `Quaternion.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, line. The changes: the classes' static functions became module functions,
vectors, geodetic positions and quaternions became plain-data instances of vectors, geodetic positions and quaternions became plain-data instances of
module-private constructors (own enumerable fields only, no methods), module-private constructors (own enumerable fields only, no methods),
+25 -3
View File
@@ -6,6 +6,9 @@
* PANEL_BASE next to the app. * 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 // The panel loads these paths (src/tools/globePanel.js); a test keeps the
// two in step. // two in step.
export const PANEL_BASE = '/panel/'; export const PANEL_BASE = '/panel/';
@@ -18,6 +21,27 @@ export function panelBuildConfig(config) {
return { return {
...config, ...config,
base: PANEL_BASE, 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: { build: {
...config.build, ...config.build,
outDir: PANEL_OUT_DIR, outDir: PANEL_OUT_DIR,
@@ -26,9 +50,7 @@ export function panelBuildConfig(config) {
cssCodeSplit: false, cssCodeSplit: false,
rollupOptions: { rollupOptions: {
...config.build?.rollupOptions, ...config.build?.rollupOptions,
// The panel inlines every dynamic import into its one script, the // The panel is a single chunk; its alias excludes the Navara engine.
// experimental Navara mode included, so the guard that keeps Navara
// out of the app's initial chunks does not apply here.
plugins: (config.build?.rollupOptions?.plugins ?? []).filter( plugins: (config.build?.rollupOptions?.plugins ?? []).filter(
(plugin) => plugin?.name !== 'gev-initial-chunk-guard', (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 // A production build must not clean the dependency cache a running dev
// server is still serving optimized module URLs from. // server is still serving optimized module URLs from.
...(command === 'build' ? { cacheDir: 'node_modules/.vite-build' } : {}), ...(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: { optimizeDeps: {
// First reached through the SDR worker or a dynamic import. Pre-bundle // First reached through the SDR worker or a dynamic import. Pre-bundle
// them at startup so first use cannot invalidate already-transformed // 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: Runtime SDK imports are restricted by directory, including unreachable modules:
`cesium` and `@cesium/*` belong in `src/globe/cesium/`; `three`, `cesium` and `@cesium/*` belong in `src/globe/cesium/`; `three`,
`postprocessing` and `@navaramap/*` belong in `src/globe/navara/`. Package `postprocessing` and `@navaramap/*` belong in `src/globe/navara/`. Package
subpaths, re-exports and literal dynamic imports obey the same rule. Tests and subpaths, re-exports and literal dynamic imports obey the same rule, and the
Node test support retain their existing exemptions. The production script `<script>` blocks of Vue components count as runtime modules. A module outside
loader is `src/app/loadCesium.js`; it imports no SDK package and needs no the Navara engine reaches it only through `import()`: a static import of the
exception. There is no blanket exception for layer `render/` directories. 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` The temporary `engineImportExceptions` list in `scripts/package-boundaries.json`
contains exact file/package pairs with reasons. The checker reports the used 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. dependency graph, excluding standalone setup and compatibility layer instances.
`src/app/layers/renderers.js` is the layer manifest. Its lazy display loaders `src/app/layers/renderers.js` is the layer manifest. Its lazy display loaders
resolve the shared factories exported by `displays.js`; `needs` names required resolve the shared factories exported by `displays.js` through `display`. All
contract primitives. The only display backend options are wind streamlines, five drawing primitives are required by the globe contract. The only display
loaded from the engine folders. A missing wind backend leaves the raster field 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 available. Wildlife Sightings draws itself through the contract; Sea Marks and
Ski Pistes use their overlay controller. No layer display imports an engine SDK. 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 `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 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 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). (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 - **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 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 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, `Pass.fullscreenGeometry` from postprocessing is disposed after the view,
which otherwise pins the canvas through a three listener. which otherwise pins the canvas through a three listener.
- **The engine contract** is `src/globe/contract.js`. `src/globe/cesium/*` and - **The engine contract** is `src/globe/contract.js`; its drawing primitives
`src/globe/navara/*` implement it, `src/globe/testing/fakeEngine.js` stands (movers, lines, ground, imagery, meshes), scene services and how to add an
in for tests, and `src/globe/testing/contractSuite.mjs` holds the checks engine are in [Globe primitives](GLOBE-PRIMITIVES.md). `src/globe/cesium/*`
every engine must pass. `npm test` runs them on the fake engine; 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 `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 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) and real canvases (`--only=ground:` and `--only=imagery:` check ground and
are skipped and named. `movers` and `lines` are required of every engine: Cesium imagery pixels); checks of state the app drives itself (contact sonar) are
draws them with billboards, points, models and polyline primitives skipped and named. Among its members: `surface.sampleTerrainMostDetailed`
(`src/globe/cesium/movers.js`, `lines.js`). (terrain heights at the finest level, fetched where not loaded),
- **Fills on the ground** (`engine.ground`) are required of every engine `maps.terrainRevision()` (changes when the terrain shown is replaced),
too. They come in sets of polygons with holes, discs, lon/lat cells or `render.maxTextureSize()` and `MoverSet.failed()` (a model or atlas that
corridors (a width in px or m), draped over terrain and 3D tiles. Each will not load). Navara also has Cesium's `contactSonarDiagnostics()`,
item has its own fill, outline and `pickId`. A set has a draw `order`, and outside the contract.
shows, hides and replaces its items in bulk. - **Navara's ground and imagery passes.** Navara draws ground fills and
- A pixel corridor can take Cesium's old looks: a `glow` corridors in one screen pass classified on the scene depth
(PolylineGlow's falloff), an alpha `pulse` and a flowing `dash` (the (`src/globe/navara/ground/`): a corridor set is tiles of binned segments
annotation route's). The GPU works them out from the time each frame; (`corridors.js`), and 10,000 corridors draw in about 12 ms still and 14 ms
while a shown set has a pulse or a dash, the engine draws a frame after moving, where Cesium takes 23 ms (the chunks before took 151 and 196 ms).
every frame, as Cesium's continuous render did. Traffic's jam heat Imagery layers draw in one screen pass after tone mapping, classified on
lines glow and pulse, and the Directions route's dashes flow (it also the scene depth (`src/globe/navara/imagery/`); the Active Fires heatmap is
holds continuous render again while drawn). mipmapped at orbit scale. Sea Marks and Ski Pistes keep Navara's native
- Cesium draws them with batched `GroundPrimitive` and raster path for tile detail and use `engine.imagery` on engines without
`GroundPolylinePrimitive`; a corridor with a look draws through one `maps.addOverlay`.
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.
- **Ground and input parity.** Space Missions draws its 500 m launch-pad - **Ground and input parity.** Space Missions draws its 500 m launch-pad
zone with the radial ground material: faint fill, bright soft rim and zone with the radial ground material: faint fill, bright soft rim and
emission, classified on terrain and tiles. Transit draws its age-banded 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 Camera input suspension is counted on every engine; the last resume restores
the state before the first suspension. Cockpit, imagery-box drawing, CCTV the state before the first suspension. Cockpit, imagery-box drawing, CCTV
calibration and infrastructure flights use that shared ownership API. 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 coupling.** `node scripts/report-engine-coupling.mjs` counts the
engine-specific code outside the engines: renderer lines per engine, Cesium engine-specific code outside the engines: renderer lines per engine, Cesium
imports, `viewer` mentions, engine-name checks, raw Navara reads, Navara imports, `viewer` mentions, engine-name checks, raw Navara reads, Navara
ground passes, Cesium SDK loads and `ScreenSpaceEventHandler` users. ground passes, Cesium SDK loads and `ScreenSpaceEventHandler` users.
`scripts/engineCoupling.test.mjs` (in `npm test`) fails when a count grows `scripts/engineCoupling.test.mjs` (in `npm test`) fails when a count grows
past `scripts/engine-coupling-baseline.json`. past `scripts/engine-coupling-baseline.json`.
- **SDK boundary and display rehearsal.** The import-direction gate permits - **Display rehearsal.** `src/app/layers/displayRehearsal.test.mjs` loads all
Cesium packages only in `src/globe/cesium/` and three, postprocessing and 35 display entries from the manifest on a fake engine with an unknown kind
Navara packages only in `src/globe/navara/`. The measured temporary exceptions and no native extensions, draws fixtures, hides or releases them, and checks
in `scripts/package-boundaries.json` are the three Bhote Koshi model modules: primitives, subscriptions and service records before engine destruction.
event, locator and embedded media still build Cesium property records. The The three self-drawing entries are named skips. This is a display-level
gate rejects unused exceptions. `src/app/layers/displayRehearsal.test.mjs` rehearsal; application kind registration and native debug access remain
loads all 35 display entries from the manifest on a fake engine with an work.
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 - **Scene transition ownership.** `src/app/sceneHost.js` serializes engine
requests, application attachment and direct shell calls. One transaction requests, application attachment and direct shell calls. One transaction
covers handoff, renderer release, candidate acquisition, attachment, view 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. hidden-tab settling and parked-renderer reuse retain their existing behavior.
A replacement application registers fresh parts on the same transition owner; A replacement application registers fresh parts on the same transition owner;
a shell used alone creates its own scene host. a shell used alone creates its own scene host.
- **Scene pick ownership.** Each attachment owns `scene.services.picking` and - **Scene services per attachment.** Each attachment owns its picking,
`scene.services.selection` (`src/data/sceneServices.js`). Layers and displays selection, render demand, world overlay, tracked readout and context
resolve these services with their attached engine; simultaneous engines do services (`src/data/sceneServices.js`; described under Input ownership,
not share owner predicates or click subscriptions. Detach releases both Render demand and World overlays in [Globe primitives](GLOBE-PRIMITIVES.md)).
services before the renderer is parked. Reattachment creates fresh services, On a switch, detach releases them before the renderer is parked and a
while retained layers register their current picks again. reattachment, parked engines included, creates fresh ones; retained layers
- **Scene render demand.** `scene.services.render` owns the attachment's holds register their picks again and resolve their attached engine, an old
and recent idle requests. It drives `engine.render` on both engines; service cannot release a replacement's render hold, and destroyed services
Cesium implements its render mode directly. Retained layers identify their stay inert. The shell reconnects its overlay action subscription; shared
attached engine, and scene callbacks capture their governor. Teardown clears font measurements live until the last overlay host detaches. Engine handoff
only that scene's holds; an old service cannot release a replacement's hold. carries the retained page selection into the incoming context, and readout
- **Scene world overlays.** `scene.services.overlay` owns each attachment's initialization leaves it intact while the outgoing display is hidden and
label sources, paint lanes, collision domains, canvases, observers, timers the incoming layer waits for camera restoration. Label text, live visual
and accessible actions. Both canvases live inside the engine container; anchors and follow-camera anti-jitter are unchanged.
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.
- **The session owns the state.** Catalog, layer models, sources, selection, - **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, gets `attachRenderer(viewer, { engine })` / `detachRenderer()` instead of being rebuilt,
so its data is not fetched again. The outgoing globe is parked so its data is not fetched again. The outgoing globe is parked
(`src/standalone/rendererCache.js`), so switching back within three minutes (`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 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 then builds it cold (1.5-2 s). `?parkedMs=` shortens the delay for QA
(`scripts/qa-strict-parked-release.mjs`). (`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 - **Shared layer displays.** Every manifest display draws through the globe
contract from `render/display.js`; the aircraft family shares its display contract from `render/display.js`; the aircraft family shares its display
implementation. There are 35 display entries (including the three weather 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; remain on their existing renderers pending bounded classification support;
the Cesium implementations live in `src/globe/cesium/annotations/`. the Cesium implementations live in `src/globe/cesium/annotations/`.
- **One layer manifest.** `LAYER_MANIFEST` in `src/app/layers/renderers.js` - **One layer manifest.** `LAYER_MANIFEST` in `src/app/layers/renderers.js`
says how each directory layer draws: its display with the contract says how each directory layer draws: its shared `display` (with an optional
primitives it needs (and an engine-specific backend option keyed by engine engine-specific `backends` option keyed by engine kind: wind streamlines), or
kind: wind streamlines), a layer that a layer that draws itself through the contract (Wildlife Sightings). Every
draws itself through the contract (Wildlife Sightings), or, without a engine implements all five drawing primitives, so the manifest lists no
display, its drawing per engine kind. Weather, wind imagery and Bhote Koshi primitives a display needs and registers no supported engine kinds; Sea
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. 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 - **One tools tree.** `src/app/tools.js` attaches the same tools to every
engine (`attachEngineTools`): the annotation board, Draw, Import, the engine (`attachEngineTools`): the annotation board, Draw, Import, the
Recent Imagery box and panel, the render governor, the scope mask, 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. `bitExact.test.mjs` compares with Cesium bit for bit.
**Invariant: no module reads Cesium while it loads** **Invariant: no module reads Cesium while it loads**
(`scripts/check-cesium-load-time.mjs`, run by `npm test`). (`scripts/check-cesium-load-time.mjs`, run by `npm test`).
- **Boundaries.** Cesium is imported only from `render/cesium*.js` and - **Boundaries.** The import-direction gate keeps Cesium packages in
`src/globe/cesium/*`; three, postprocessing and Navara only from `navara*` `src/globe/cesium/` and three, postprocessing and Navara packages in
modules, which load through `import()`. `build/initialChunkGuard.js` fails `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 the build when one of them reaches an initial chunk, and
`npm run check:main-chunk` holds the main chunk to its gzip budget `npm run check:main-chunk` holds the main chunk to its gzip budget
(`scripts/main-chunk-baseline.json`). Only `src/globe/navara/internals.js` (`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 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. 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 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 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 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 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, **Selected history.** Selecting one vehicle draws its available recent path,
clipped to the displayed time. The browser retains up to 15 minutes and 128 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 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 shorten that history. Only the selected vehicle has a trail, drawn as
engine: completed segments draw in two lanes (a 5 px dark edge under a 3 px `engine.ground` corridors draped on terrain and 3D tiles on every engine:
mode colour), one line per lane for each run of joined segments in one of 29 completed segments draw in two lanes (a 5 px dark edge under a 3 px mode
age bands, re-banded once per displayed second or style change; the live head colour), one corridor per lane for each run of joined segments in one of 29
runs from its segment's start to the marker and is rewritten only when the age bands, re-banded once per displayed second or style change. The active
marker moves. Where the scene hides the trail, the colour lane draws at up to segment is one corridor with its dark backing; its geometry is written once
0.55 alpha. Trails are not draped yet (the contract's ground corridor will per segment and a shader reveal (`setReveal`) draws it up to the marker, so it
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 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. active and future corridors.
MBTA history is also retained in the running proxy: up to 15 minutes and 128 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. 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. 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. 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. - **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. - **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. - 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) ### 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. - 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. - **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.** 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. - **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`. - **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. - **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. - **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. counters, and timing labels.
- Voice debug log: `tail -f .gev-logs/realtime-conversations.jsonl` (gitignored). - 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. 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 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 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 ## Lines: polylines in the air
@@ -87,8 +89,10 @@ Item options:
draws on top. All ground fills draw under movers, lines and models. draws on top. All ground fills draw under movers, lines and models.
- `pickable: true` makes picks return an item's `pickId`, with `owner` (the - `pickable: true` makes picks return an item's `pickId`, with `owner` (the
set) and `instance` (the item's index). set) and `instance` (the item's index).
- `drape` is `'both'` (the default), `'terrain'` or `'tiles'`. Cesium - `drape` is `'both'` (the default), `'terrain'` or `'tiles'`. Both engines
honours it; Navara drapes on whatever surface it draws. 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, Discs with a fill may use `radial: { fillAlpha, rimAlpha, rimStart, rimEnd,
emission }`. Alpha multipliers are in 0..1; the radius fractions satisfy 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 - **Imported KML/KMZ ground images** (`src/data/geoImportDisplay.js`): one
image layer per rectangle at order 30, with the authored alpha capped at 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 - **Sea Marks and Ski Pistes** (`src/data/osmOverlays.js`): tile layers at
order 10 on engines without `maps.addOverlay`. 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`. Cesium's precedence; its tests compare the records with `KmlDataSource`.
No import loads the Cesium SDK. One `geoImportDisplay.js` draws the records 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, 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, Every owned id starts with `geo-import-`; the file controller owns visibility,
framing, refresh and destruction across engine switches. framing, refresh and destruction across engine switches.
Pins use composed square sprites to retain Cesium's pin tip, hot spot, Pins use composed square sprites to retain Cesium's pin tip, hot spot,
subregion, colour, scale and rotation. Ground heights come from the loaded subregion, colour, scale and rotation; a KML heading turns with the camera's
surface, refreshed after the camera or map settles. Ground pins ignore scene heading, so it stays measured from north. Icon textures share a per-file
depth and are culled beyond the horizon; absolute pins retain their height. 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 Raised polygons (KML absolute or relative) draw their fills as meshes and
prisms are omitted (their top geometry remains), and `gx:LatLonQuad` is their outlines as lines at the vertices' heights. A ground overlay's colour
omitted. These are counted as undrawn. Raised polygon fills drape; their tints its image and its alpha fades it; a rotated overlay's image is turned
outlines retain altitude. Ground-overlay rotation, altitude and RGB tint are on a canvas over the box around it, as Cesium turns its rectangle in degrees,
not applied, and ground edges use great circles rather than rhumb lines. and an absolute overlay is raised to its altitude (Cesium raises only
These appearance limits are not included in the undrawn count. 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 ## 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, Implement the required host, camera, picker, projector, frame, render, surface,
input, credit, clock and map interfaces, plus all five drawing primitives: input, credit, clock and map interfaces, plus all five drawing primitives:
`movers`, `lines`, `ground`, `imagery` and `meshes`. `engineCapabilities(engine)` `movers`, `lines`, `ground`, `imagery` and `meshes`. `assertGlobeEngine(engine)`
reports these and the optional visualStyles, bloom, cloudCover and contactSonar checks these required interfaces. `engineCapabilities(engine)` reports only the
groups. An optional group must be complete or absent. Do not advertise a optional visualStyles, bloom, cloudCover and contactSonar groups. An optional
capability with a no-op implementation. Match Cesium's behavior where the 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. contract permits both engines to implement it.
Run `createContractSuite(engine, { waitFrames, ... })` from 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 that arbitrary engine folders are auto-discovered today. Layers and their
fixtures should need no new drawing implementation. fixtures should need no new drawing implementation.
The layer manifest (`src/app/layers/renderers.js`) loads shared displays and The layer manifest (`src/app/layers/renderers.js`) loads shared factories through
lists their `needs`. Today every valid engine must implement all five `display`. Every valid engine implements all five primitives; the manifest has
primitives; availability is checked against the registered kind's primitive no per-kind primitive lists or `needs` declarations. `backends` supplies optional
list. `backends` supplies optional engine-specific display options, currently engine-specific display options, currently only wind's `flow`, loaded dynamically
only wind's `flow`. Load a backend dynamically from its engine folder. A new from its engine folder. A missing wind backend leaves the shared raster field
engine can omit wind streamlines and still draw the shared raster field. Do not available. Self-drawing layers and map overlays use the same contract and do not
copy another engine's extensions into a new engine. The self-drawing map overlay register supported engine kinds in the manifest.
entries currently list supported kinds explicitly and need registration too.
Run `src/app/layers/displayRehearsal.test.mjs`: it loads every manifest display 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 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 { existsSync, readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { parse } from '@babel/parser'; import { parse } from '@babel/parser';
import { vueScripts } from './vue-scripts.mjs';
const root = new URL('..', import.meta.url); const root = new URL('..', import.meta.url);
@@ -24,7 +25,7 @@ export function sourceFiles() {
.split('\n') .split('\n')
.filter( .filter(
(path) => (path) =>
/\.(?:js|mjs)$/.test(path) && /\.(?:js|mjs|vue)$/.test(path) &&
!/\.test\.m?js$/.test(path) && !/\.test\.m?js$/.test(path) &&
!path.startsWith('src/testSupport/') && !path.startsWith('src/testSupport/') &&
!path.includes('/testing/') && !path.includes('/testing/') &&
@@ -75,7 +76,7 @@ function isReference(node, parent, key) {
parent.type === 'ImportSpecifier' || parent.type === 'ImportSpecifier' ||
parent.type === 'ImportNamespaceSpecifier' || parent.type === 'ImportNamespaceSpecifier' ||
parent.type === 'ImportDefaultSpecifier' || parent.type === 'ImportDefaultSpecifier' ||
parent.type === 'ExportSpecifier' (parent.type === 'ExportSpecifier' && key !== 'local')
) )
return false; return false;
if (parent.type === 'LabeledStatement' || parent.type === 'BreakStatement') 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. * members and heritage run at load and stay not deferred.
*/ */
export function loadTimeReferences(code, path = '<module>') { 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, { const program = parse(code, {
sourceType: 'module', sourceType: 'module',
plugins: ['classProperties', 'classStaticBlock', 'importAttributes'], plugins: ['classProperties', 'classStaticBlock', 'importAttributes'],
+24
View File
@@ -40,3 +40,27 @@ test('no shipped module reads Cesium while it loads', () => {
); );
assert.deepEqual(found, []); 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( const builtins = new Set(
builtinModules.flatMap((name) => [name, `node:${name}`]), builtinModules.flatMap((name) => [name, `node:${name}`]),
); );
const code = /\.[mc]?js$/; const code = /\.(?:[mc]?js|vue)$/;
const entry = (file) => const entry = (file) =>
file === 'src/main.js' || file.startsWith('src/standalone/'); file === 'src/main.js' || file.startsWith('src/standalone/');
const tests = (file) => const tests = (file) =>
@@ -30,12 +30,6 @@ const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file);
const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/; const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/;
// Navara renders through three.js and its postprocessing peer. // Navara renders through three.js and its postprocessing peer.
const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/; 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) => const renderer = (file) =>
/^src\/(?:ui|app|standalone|overlays)\//.test(file) || /^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test( /^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
@@ -125,6 +119,7 @@ export function checkImportDirections(root) {
try { try {
analysis = analyzeModule(readFileSync(resolved, 'utf8'), { analysis = analyzeModule(readFileSync(resolved, 'utf8'), {
dynamic: true, dynamic: true,
filename: file,
}); });
} catch (error) { } catch (error) {
report(file, error.message); report(file, error.message);
@@ -188,27 +183,12 @@ export function checkImportDirections(root) {
if ( if (
to.startsWith('src/globe/navara/') && to.startsWith('src/globe/navara/') &&
!file.startsWith('src/globe/navara/') && !file.startsWith('src/globe/navara/') &&
!analysis.dynamicImports.includes(specifier) analysis.staticImports.includes(specifier)
) )
report( report(
file, file,
`The Navara engine loads only through a dynamic import(): ${to}`, `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/')) if (file.startsWith('src/') && to.startsWith('server/'))
report(file, `Browser imports server: ${to}`); report(file, `Browser imports server: ${to}`);
if ( if (
@@ -322,8 +302,8 @@ export function checkImportDirections(root) {
} }
for (const file of files.filter(globeContract)) globe(file); for (const file of files.filter(globeContract)) globe(file);
// Each engine graph stays on its own engine, through helpers too. // Each engine graph stays on its own engine, through helpers too.
for (const start of files.filter(globeEngine)) { for (const owner of ['cesium', 'navara']) {
const navara = start.startsWith('src/globe/navara/'); const navara = owner === 'navara';
const label = navara ? 'Navara globe engine' : 'Cesium globe engine'; const label = navara ? 'Navara globe engine' : 'Cesium globe engine';
const foreignPackage = navara ? cesiumPackage : navaraPackage; const foreignPackage = navara ? cesiumPackage : navaraPackage;
const foreignEngine = navara ? 'src/globe/cesium/' : 'src/globe/navara/'; const foreignEngine = navara ? 'src/globe/cesium/' : 'src/globe/navara/';
@@ -342,7 +322,10 @@ export function checkImportDirections(root) {
else visit(to); 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. // Common voice controls may use their DOM view, but never a protocol implementation.
const voiceSeen = new Set(); const voiceSeen = new Set();
+9 -3
View File
@@ -1,4 +1,5 @@
import { parsers } from 'prettier/plugins/babel.mjs'; import { parsers } from 'prettier/plugins/babel.mjs';
import { vueScripts } from './vue-scripts.mjs';
const browserGlobals = new Set([ const browserGlobals = new Set([
'window', 'window',
@@ -19,9 +20,10 @@ const browserGlobals = new Set([
* Inspect literal module dependencies and browser-platform references without * Inspect literal module dependencies and browser-platform references without
* executing code. With `dynamic`, also list the import() specifiers apart. * 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 imports = [];
const dynamicImports = []; const dynamicImports = [];
const staticImports = [];
const browser = new Set(); const browser = new Set();
function walk(node, parent, field) { function walk(node, parent, field) {
if (!node || typeof node !== 'object') return; if (!node || typeof node !== 'object') return;
@@ -41,6 +43,7 @@ export function analyzeModule(source, { dynamic = false } = {}) {
imports.push(node.source.value); imports.push(node.source.value);
if (node.type === 'ImportExpression') if (node.type === 'ImportExpression')
dynamicImports.push(node.source.value); dynamicImports.push(node.source.value);
else staticImports.push(node.source.value);
} }
if (node.type === 'CallExpression' && node.callee?.name === 'require') if (node.type === 'CallExpression' && node.callee?.name === 'require')
throw new Error('Runtime modules must use ES imports'); 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); 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 { return {
imports, imports,
browser: [...browser], browser: [...browser],
...(dynamic && { dynamicImports }), ...(dynamic && { dynamicImports, staticImports }),
}; };
} }
+4
View File
@@ -2259,6 +2259,7 @@
"src/layers/shared/selectionKeeper.js", "src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/layers/submarineCables/render/display.js", "src/layers/submarineCables/render/display.js",
"src/layers/shared/render/surfacePoints.js",
"src/overlays/bindOverlayHost.js" "src/overlays/bindOverlayHost.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -3595,6 +3596,7 @@
"src/layers/earthquakes/render/display.js", "src/layers/earthquakes/render/display.js",
"src/layers/airQuality/render/display.js", "src/layers/airQuality/render/display.js",
"src/layers/submarineCables/render/display.js", "src/layers/submarineCables/render/display.js",
"src/layers/shared/render/surfacePoints.js",
"src/layers/firms/render/sprites.js", "src/layers/firms/render/sprites.js",
"src/layers/cyclones/render/display.js", "src/layers/cyclones/render/display.js",
"src/layers/droneAirspace/render/display.js", "src/layers/droneAirspace/render/display.js",
@@ -4352,6 +4354,7 @@
"src/layers/gnss/render/display.js", "src/layers/gnss/render/display.js",
"src/layers/firms/render/sprites.js", "src/layers/firms/render/sprites.js",
"src/layers/submarineCables/render/display.js", "src/layers/submarineCables/render/display.js",
"src/layers/shared/render/surfacePoints.js",
"src/layers/perimeters/render/display.js", "src/layers/perimeters/render/display.js",
"src/layers/droneAirspace/render/display.js", "src/layers/droneAirspace/render/display.js",
"src/globe/ground.js", "src/globe/ground.js",
@@ -4539,6 +4542,7 @@
"ui-share-restoration": { "ui-share-restoration": {
"exports": ["./ui/share-restoration"], "exports": ["./ui/share-restoration"],
"modules": [ "modules": [
"src/ui/layerNames.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"src/data/installationFeedback.js", "src/data/installationFeedback.js",
"src/data/idSwitches.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 // paths in one engine.ground corridor set, landing points and reference
// tips in mover sets, stems in engine.lines, labels on the world-overlay // tips in mover sets, stems in engine.lines, labels on the world-overlay
// host. It owns no Cesium DataSource or Entity any more. // 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 gev = window.__godsEyeView;
const v = gev.viewer; const v = gev.viewer;
const engine = gev.engine; const engine = gev.engine;
@@ -126,6 +126,7 @@ try {
out.layerDataSources++; out.layerDataSources++;
out.layerEntities += ds.entities.values.length; out.layerEntities += ds.entities.values.length;
} }
if (legacy) return out;
for (const set of engine.ground.list()) { for (const set of engine.ground.list()) {
if (set.id !== `${layerId}-paths`) continue; if (set.id !== `${layerId}-paths`) continue;
out.pathSets++; out.pathSets++;
@@ -146,7 +147,7 @@ try {
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {}; const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
out.referenceLabelCount = stats.referenceLabelCount ?? null; out.referenceLabelCount = stats.referenceLabelCount ?? null;
return out; return out;
}, LAYER_ID); }, LAYER_ID, legacy);
report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts); report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts);
// ── a. per-frame scene.render cost over a ~10 s driven orbit ───────── // ── 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 scene = viewer.scene;
const canvas = scene.canvas; const canvas = scene.canvas;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const { hitTestWorldOverlay } = const { hitTest } = (
(await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); await import('/src/overlays/worldOverlay.js')
).worldOverlayFor(window.__godsEyeView.engine);
const candidates = [ const candidates = [
[0.55, 0.72], [0.55, 0.72],
[0.45, 0.72], [0.45, 0.72],
@@ -1508,7 +1509,7 @@ async function main() {
const x = Math.round(canvas.clientWidth * fx); const x = Math.round(canvas.clientWidth * fx);
const y = Math.round(canvas.clientHeight * fy); const y = Math.round(canvas.clientHeight * fy);
const picked = scene.pick({ x, y }); 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); const top = document.elementFromPoint(rect.left + x, rect.top + y);
if ( if (
resolveId(picked) === null && resolveId(picked) === null &&
+1 -2
View File
@@ -36,8 +36,7 @@ const check = (name, passed, detail = '') => {
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
headless: process.env.QA_HEADFUL === '1' ? false : true, headless: process.env.QA_HEADFUL === '1' ? false : true,
executablePath: executablePath: chromeExecutablePath(),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: [ args: [
'--no-sandbox', '--no-sandbox',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
+3 -1
View File
@@ -65,7 +65,9 @@ await strict.run({}, async (browser) => {
(kind) => (kind) =>
window.__godsEyeView?.engine?.kind === kind && window.__godsEyeView?.engine?.kind === kind &&
document.querySelector('#globe-engine')?.value === 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'), document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90000 }, { timeout: 90000 },
kind, kind,
+18 -31
View File
@@ -2,7 +2,12 @@ import { assertKeylessPreview } from './qa-strict-harness.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { installAlprFixture, cameras } from './fixtures/layers/alpr.mjs'; import { installAlprFixture, cameras } from './fixtures/layers/alpr.mjs';
import { firmsFixture, FIRMS_VALID_COUNT } from './fixtures/layers/firms.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]), const url = localPreview(process.argv[2]),
fire = process.argv.includes('--fires'), fire = process.argv.includes('--fires'),
id = fire ? 'local-firms' : 'alpr-cameras'; 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). // A click on empty ground (nothing picked there, nothing covering it).
const clickEmpty = async () => { const clickEmpty = async () => {
const handle = await p.waitForFunction( await clickEmptyGround(p, {
() => { left: 440,
const { engine } = window.__godsEyeView; top: 240,
const canvas = engine.host.canvas; right: 840,
const box = canvas.getBoundingClientRect(); bottom: 600,
for (let y = 240; y < 600; y += 9) step: 9,
for (let x = 440; x < 840; x += 9) { radius: 6,
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 p.waitForFunction( await p.waitForFunction(
(fire) => (fire) =>
fire fire
@@ -165,6 +148,10 @@ await strict.run({}, async (b) => {
xy, xy,
); );
await new Promise((r) => setTimeout(r, 1600)); 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 const expectedId = fire
? await p.evaluate(() => window.qaGround.getSelectedInfo().id) ? await p.evaluate(() => window.qaGround.getSelectedInfo().id)
: xy.id; : xy.id;
+60 -51
View File
@@ -9,6 +9,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import sharp from 'sharp'; import sharp from 'sharp';
import { fromDegrees } from '../src/geo/cartesian3.js';
import { watchBrowserPage, redact } from './qa-browserEvidence.mjs'; import { watchBrowserPage, redact } from './qa-browserEvidence.mjs';
const urlIndex = process.argv.indexOf('--url'); const urlIndex = process.argv.indexOf('--url');
@@ -150,23 +151,25 @@ async function open(mode = 'normal') {
'0'), '0'),
{ timeout: 60000 }, { timeout: 60000 },
); );
await page.evaluate(async () => { await page.evaluate(
const { dataManager, engine } = window.__godsEyeView; async (destination) => {
await window.__godsEyeView.styleManager.initialRestorePromise; const { dataManager, engine } = window.__godsEyeView;
window.__godsEyeView.styleManager.orbitController.stop(); await window.__godsEyeView.styleManager.initialRestorePromise;
for (const [id, layer] of dataManager.layers) { window.__godsEyeView.styleManager.orbitController.stop();
if (layer.enabled) for (const [id, layer] of dataManager.layers) {
await dataManager.setEnabled(id, false, { origin: 'user' }); if (layer.enabled)
} await dataManager.setEnabled(id, false, { origin: 'user' });
const geo = await import('/src/geo/index.js'); }
engine.camera.cancelFlight(); engine.camera.cancelFlight();
engine.camera.setView({ engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000), destination,
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
}); });
await dataManager.setEnabled('bikeshare', true, { origin: 'user' }); await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
window.__godsEyeView.requestRender('gbfs-name-qa'); window.__godsEyeView.requestRender('gbfs-name-qa');
}); },
fromDegrees(-97.7449, 30.2701, 3000),
);
return { page, errors }; return { page, errors };
} }
@@ -220,15 +223,17 @@ async function w2Checks(page) {
['close', 3000], ['close', 3000],
['global', 100000], ['global', 100000],
]) { ]) {
await page.evaluate(async (height) => { await page.evaluate(
const app = window.__godsEyeView; async (destination) => {
await app.dataManager.setEnabled('bikeshare', false); const app = window.__godsEyeView;
const geo = await import('/src/geo/index.js'); await app.dataManager.setEnabled('bikeshare', false);
app.engine.camera.setView({ app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, height), destination,
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
}); });
}, height); },
fromDegrees(-97.7449, 30.2701, height),
);
await settledTiles(page); await settledTiles(page);
await nextFrame(page); await nextFrame(page);
const off = await page.screenshot({ const off = await page.screenshot({
@@ -304,15 +309,17 @@ async function w2Checks(page) {
for (const element of document.querySelectorAll('.gbfs-raster-hidden')) for (const element of document.querySelectorAll('.gbfs-raster-hidden'))
element.classList.remove('gbfs-raster-hidden'); element.classList.remove('gbfs-raster-hidden');
}); });
await page.evaluate(async () => { await page.evaluate(
const app = window.__godsEyeView; async (destination) => {
const geo = await import('/src/geo/index.js'); const app = window.__godsEyeView;
app.engine.camera.setView({ app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000), destination,
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
}); });
await app.dataManager.setEnabled('bikeshare', false); await app.dataManager.setEnabled('bikeshare', false);
}); },
fromDegrees(-97.7449, 30.2701, 3000),
);
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
await page.evaluate(() => await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', true), window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
@@ -520,22 +527,24 @@ try {
); );
} }
await page.setViewport({ width: 390, height: 844 }); await page.setViewport({ width: 390, height: 844 });
await page.evaluate(async () => { await page.evaluate(
window.__qaPainted = []; async (destination) => {
const app = window.__godsEyeView; window.__qaPainted = [];
// Use the supported clean view to inspect the mobile card clear of panels. const app = window.__godsEyeView;
app.styleManager.toggleCleanView(true); // Use the supported clean view to inspect the mobile card clear of panels.
const geo = await import('/src/geo/index.js'); app.styleManager.toggleCleanView(true);
app.engine.camera.cancelFlight(); app.engine.camera.cancelFlight();
app.engine.camera.setView({ app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000), destination,
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
}); });
app.dataManager.layers app.dataManager.layers
.get('bikeshare') .get('bikeshare')
.module._selectBikeshareStationForTest('austin-capmetro:english'); .module._selectBikeshareStationForTest('austin-capmetro:english');
app.requestRender('gbfs-name-mobile-qa'); app.requestRender('gbfs-name-mobile-qa');
}); },
fromDegrees(-97.7449, 30.2701, 3000),
);
await page.waitForFunction( await page.waitForFunction(
() => window.__qaPainted.includes('QA English Square'), () => window.__qaPainted.includes('QA English Square'),
{ timeout: 10000 }, { timeout: 10000 },
+15 -31
View File
@@ -2,9 +2,11 @@ import { fromDegrees } from '../src/geo/cartesian3.js';
import sharp from 'sharp'; import sharp from 'sharp';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
clickEmptyGround,
createStrict, createStrict,
localPreview, localPreview,
openPage, openPage,
waitForCameraAtRest,
waitForEngine, waitForEngine,
} from './qa-strict-harness.mjs'; } 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 // 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. // empty water, then select the vessel with a click on its chevron.
const pickVessel = async () => { 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 // Empty ground: a point in the middle of the view where the engine
// picks nothing and nothing covers the canvas. // picks nothing and nothing covers the canvas.
const empty = await p await clickEmptyGround(p, {
.waitForFunction( left: 300,
() => { top: 220,
const { engine } = window.__godsEyeView; right: 720,
const canvas = engine.host.canvas; bottom: 560,
const box = canvas.getBoundingClientRect(); step: 11,
for (let y = 220; y < 560; y += 11) radius: 10,
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 p.waitForFunction( await p.waitForFunction(
() => !window.qaVesselEntry.module.getSelectedInfo(), () => !window.qaVesselEntry.module.getSelectedInfo(),
); );
@@ -447,6 +429,8 @@ await strict.run({}, async (b) => {
'ais-live-vessels', 'ais-live-vessels',
'a click on the chevron selects the vessel', '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 click(-79.38, 43.65);
await p.waitForFunction( await p.waitForFunction(
+11 -13
View File
@@ -10,8 +10,9 @@ export async function checkInstallationPan(page, dir) {
); );
await sleep(3000); await sleep(3000);
await page.evaluate(async () => { await page.evaluate(async () => {
const { getOverlayPaintRect } = const { getPaintRect } = (
(await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); await import('/src/overlays/worldOverlay.js')
).worldOverlayFor(window.__godsEyeView.engine);
const g = window.__godsEyeView; const g = window.__godsEyeView;
const installations = g.dataManager.layers.get( const installations = g.dataManager.layers.get(
'military-installations', 'military-installations',
@@ -28,10 +29,10 @@ export async function checkInstallationPan(page, dir) {
const sites = [], const sites = [],
centers = []; centers = [];
installations.visitNamedMarkers((id) => { installations.visitNamedMarkers((id) => {
if (getOverlayPaintRect('military-installations', id)) sites.push(id); if (getPaintRect('military-installations', id)) sites.push(id);
}); });
for (const id of centerIds) 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 }); window.__polishPanFrames.push({ t: performance.now(), sites, centers });
}); });
}); });
@@ -187,8 +188,9 @@ export async function checkInstallationSelection(
window.__polishSelectionFrames = []; window.__polishSelectionFrames = [];
const { getSelectedEntityContext } = const { getSelectedEntityContext } =
await import('/src/data/contextStore.js'); await import('/src/data/contextStore.js');
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = const { getPaintRect, getDiagnostics } = (
(await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); await import('/src/overlays/worldOverlay.js')
).worldOverlayFor(window.__godsEyeView.engine);
// The selected site's fill is one engine.ground polygon set // The selected site's fill is one engine.ground polygon set
// (render/display.js SELECTION_FILL_SET_ID), an item per footprint // (render/display.js SELECTION_FILL_SET_ID), an item per footprint
// part; the layer owns no Cesium entities or ground primitives. // part; the layer owns no Cesium entities or ground primitives.
@@ -225,17 +227,13 @@ export async function checkInstallationSelection(
g.viewer.scene.postRender.addEventListener(() => { g.viewer.scene.postRender.addEventListener(() => {
window.__polishSelectionFrames.push({ window.__polishSelectionFrames.push({
paints: window.__polishTitlePaints.splice(0).length, paints: window.__polishTitlePaints.splice(0).length,
own: Boolean( own: Boolean(getPaintRect('military-installations', record.id)),
getOverlayPaintRect('military-installations', record.id),
),
tracked: Boolean( tracked: Boolean(
getOverlayPaintRect('tracked', `installations:${record.id}`), getPaintRect('tracked', `installations:${record.id}`),
), ),
selected: getSelectedEntityContext()?.id === record.id, selected: getSelectedEntityContext()?.id === record.id,
total: total:
getWorldOverlayDiagnostics().paintedBySource[ getDiagnostics().paintedBySource['military-installations'] || 0,
'military-installations'
] || 0,
}); });
}); });
return { return {
+2 -2
View File
@@ -7,8 +7,8 @@
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after [--engine=cesium|navara] * 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 * Engine-neutral: camera, tiles and layers go through the globe engine and the
* QA probe; an engine without the flights/satellites layers (Navara today) * QA probe; an engine without the flights/satellites layers shoots the
* shoots the scenes without a contact field and says so. * scenes without a contact field and says so.
* *
* Every scene pins its own camera pose AND substitutes a deterministic * Every scene pins its own camera pose AND substitutes a deterministic
* synthetic contact field for the live pollers, so a `before` run and an * 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 * Engine-neutral: the camera and frames go through the globe engine and the
* layers through the QA probe (window.__godsEyeView.layers). On an engine * layers through the QA probe (window.__godsEyeView.layers). On an engine
* without the flights, military and satellites layers (Navara today) the * without the flights, military and satellites layers the field checks are
* field checks are SKIPPED and the settings checks still run. * SKIPPED and the settings checks still run.
* *
* The harness replaces only the three detection read APIs in the loaded page. * 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 * 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( page.waitForFunction(
(engine) => (engine) =>
window.__godsEyeView?.engine?.kind === 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('#loading-screen.hidden') &&
!document.querySelector('.engine-switch-frame'), !document.querySelector('.engine-switch-frame'),
{ timeout: 90000 }, { timeout: 90000 },
+13 -13
View File
@@ -152,14 +152,15 @@ try {
await settle(); await settle();
const before = await stats(); const before = await stats();
const overlaps = await page.evaluate(async () => { const overlaps = await page.evaluate(async () => {
const { getOverlayPaintRect } = const { getPaintRect } = (
(await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); await import('/src/overlays/worldOverlay.js')
).worldOverlayFor(window.__godsEyeView.engine);
const layer = window.__godsEyeView.dataManager.layers.get( const layer = window.__godsEyeView.dataManager.layers.get(
'military-installations', 'military-installations',
).module; ).module;
const rects = []; const rects = [];
layer.visitNamedMarkers((id) => { layer.visitNamedMarkers((id) => {
const rect = getOverlayPaintRect('military-installations', id); const rect = getPaintRect('military-installations', id);
if (rect) rects.push(rect); if (rect) rects.push(rect);
}); });
let overlaps = 0; let overlaps = 0;
@@ -223,9 +224,7 @@ try {
opacityProperty: true, opacityProperty: true,
}), }),
labels: labels:
host.getWorldOverlayDiagnostics().paintedBySource[ host.getDiagnostics().paintedBySource['military-installations'] || 0,
'military-installations'
] || 0,
}; };
}); });
assert.ok( assert.ok(
@@ -244,10 +243,10 @@ try {
), ),
); );
result.disabled = await page.evaluate(async () => { result.disabled = await page.evaluate(async () => {
const host = (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); const host = (
return host.getWorldOverlayDiagnostics().entriesBySource[ await import('/src/overlays/worldOverlay.js')
'military-installations' ).worldOverlayFor(window.__godsEyeView.engine);
]; return host.getDiagnostics().entriesBySource['military-installations'];
}); });
assert.equal(result.disabled, 0); assert.equal(result.disabled, 0);
await page.evaluate(() => await page.evaluate(() =>
@@ -266,8 +265,9 @@ try {
} }
await page.evaluate(async () => { await page.evaluate(async () => {
const C = await import('/node_modules/cesium/Build/Cesium/index.js'); const C = await import('/node_modules/cesium/Build/Cesium/index.js');
const { getOverlayPaintRect } = const { getPaintRect } = (
(await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); await import('/src/overlays/worldOverlay.js')
).worldOverlayFor(window.__godsEyeView.engine);
const g = window.__godsEyeView; const g = window.__godsEyeView;
const layer = g.dataManager.layers.get('military-installations').module; const layer = g.dataManager.layers.get('military-installations').module;
const overlay = document.createElement('div'); const overlay = document.createElement('div');
@@ -284,7 +284,7 @@ try {
labels = [], labels = [],
rects = []; rects = [];
layer.visitNamedMarkers((id, point) => { layer.visitNamedMarkers((id, point) => {
const rect = getOverlayPaintRect('military-installations', id); const rect = getPaintRect('military-installations', id);
if (rect) rects.push(rect); if (rect) rects.push(rect);
const p = C.SceneTransforms.worldToWindowCoordinates( const p = C.SceneTransforms.worldToWindowCoordinates(
g.viewer.scene, g.viewer.scene,
+3 -1
View File
@@ -48,7 +48,9 @@ await strict.run(
(k) => (k) =>
window.__godsEyeView?.engine?.kind === k && window.__godsEyeView?.engine?.kind === k &&
window.__gevDrawTool?.attached && window.__gevDrawTool?.attached &&
!document.querySelector('#globe-engine')?.disabled && !document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
) &&
!document.querySelector('.engine-switch-frame') && !document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'), document.querySelector('#loading-screen.hidden'),
{ timeout: 120000 }, { timeout: 120000 },
+6 -4
View File
@@ -2,8 +2,8 @@
// UTILITIES ▸ Import on Navara under the strict network policy: GeoJSON, // UTILITIES ▸ Import on Navara under the strict network policy: GeoJSON,
// KML, KMZ (a packed icon and a ground overlay) and GPX through the real // 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 // 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 // their pins and packed ground overlay; unsupported content is counted
// so in the hint; hide, fly-to and remove work on the engine; the files // 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 // 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 // the page request anything it names (the strict policy fails any request
// outside the preview and the free tile cache). // outside the preview and the free tile cache).
@@ -152,7 +152,9 @@ await strict.run(
(k) => (k) =>
window.__godsEyeView?.engine?.kind === k && window.__godsEyeView?.engine?.kind === k &&
window.__gevGeoImport?.diagnostics().attached && 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('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'), document.querySelector('#loading-screen.hidden'),
{ timeout: 120000 }, { timeout: 120000 },
@@ -171,7 +173,7 @@ await strict.run(
rows: document.querySelectorAll('#geo-import-list .geo-import-row') rows: document.querySelectorAll('#geo-import-list .geo-import-row')
.length, .length,
hint: document.getElementById('geo-import-hint')?.textContent || '', 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 pins: window.__godsEyeView.engine.movers
.list() .list()
.filter(({ id }) => id.startsWith('geo-import-')) .filter(({ id }) => id.startsWith('geo-import-'))
+9 -9
View File
@@ -18,7 +18,7 @@
* node scripts/qa-overlay-baseline.mjs --engine=navara * node scripts/qa-overlay-baseline.mjs --engine=navara
* *
* Engine-neutral: the camera and frames go through the globe engine and the * 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 * 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 * counts the globe contract's sets (movers, lines, ground, imagery) on both
* engines; on Cesium it also counts what is left of Entities, LabelGraphics * 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 os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import { fromDegrees } from '../src/geo/cartesian3.js';
const CHROME_EXECUTABLE = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; const CHROME_EXECUTABLE = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const DEFAULT_URL = 'http://localhost:4176'; const DEFAULT_URL = 'http://localhost:4176';
@@ -362,7 +363,7 @@ async function installPageInstrumentation(page) {
async function waitForApp(page) { async function waitForApp(page) {
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction( await page.waitForFunction(
() => Boolean(window.__godsEyeView?.layers && window.__godsEyeView?.dataManager), () => Boolean(window.__godsEyeView?.engine && window.__godsEyeView?.dataManager),
{ timeout: 60_000 }, { timeout: 60_000 },
); );
await page.evaluate(() => { await page.evaluate(() => {
@@ -375,17 +376,16 @@ async function waitForApp(page) {
async function setCamera(page, camera) { async function setCamera(page, camera) {
if (!camera) return; if (!camera) return;
await page.evaluate(async ([lon, lat, height, heading, pitch]) => { await page.evaluate(([destination, heading, pitch]) => {
const { engine, viewer } = window.__godsEyeView; const { engine, viewer } = window.__godsEyeView;
const geo = await import('/src/geo/index.js');
engine.camera.cancelFlight(); engine.camera.cancelFlight();
viewer?.scene.tweens?.removeAll?.(); viewer?.scene.tweens?.removeAll?.();
engine.camera.setView({ engine.camera.setView({
destination: geo.cartesian3.fromDegrees(lon, lat, height), destination,
orientation: { heading, pitch, roll: 0 }, orientation: { heading, pitch, roll: 0 },
}); });
engine.render.request('qa-overlay-baseline'); engine.render.request('qa-overlay-baseline');
}, camera); }, [fromDegrees(...camera.slice(0, 3)), ...camera.slice(3)]);
} }
async function readLayerState(page, layerId) { async function readLayerState(page, layerId) {
@@ -646,7 +646,7 @@ async function prepareTrackedFlight(page, cockpit) {
}); });
if (!tracked.ok) return `Civil tracking unavailable (${tracked.reason})`; if (!tracked.ok) return `Civil tracking unavailable (${tracked.reason})`;
try { 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 { } catch {
return 'Civil tracking did not create a tracked entity'; 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'; if (!selected) return 'Mission roster did not expose a selectable mission';
try { 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 { } catch {
return 'Mission selection did not reach the layer probe'; return 'Mission selection did not reach the layer probe';
} }
@@ -689,7 +689,7 @@ async function prepareMission(page) {
async function prepareScene(page, scene) { async function prepareScene(page, scene) {
const missing = await page.evaluate( 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, scene.layers,
); );
if (missing.length) if (missing.length)
+6 -4
View File
@@ -71,7 +71,7 @@
* Usage: node scripts/qa-perf.mjs [--url http://localhost:4173] [--engine=cesium|navara] * 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 * Engine-neutral: the camera, frames and layers go through the globe engine
* and the QA probe (window.__godsEyeView.layers); on an engine without a * 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 * Requires a running dev server. Headless; flags disable occlusion
* throttling so rAF cadence is trustworthy (hidden-pane gotcha). * throttling so rAF cadence is trustworthy (hidden-pane gotcha).
*/ */
@@ -338,14 +338,16 @@ try {
await page.evaluate(() => { window.__godsEyeView.styleManager.setStyle('retro'); }); await page.evaluate(() => { window.__godsEyeView.styleManager.setStyle('retro'); });
await new Promise((r) => setTimeout(r, 900)); // crossfade + first ticks await new Promise((r) => setTimeout(r, 900)); // crossfade + first ticks
const dAnim = await diag(); const dAnim = await diag();
// The animated styles are Cesium post-process stages; Navara draws its own // This assertion checks the Cesium style-anim render hold.
// looks and has no style animation (a known gap, not a regression).
if (engineKind === 'cesium') { if (engineKind === 'cesium') {
check('animated style takes the style-anim hold (continuous)', dAnim?.mode === 'continuous' && dAnim.holds.includes('style-anim'), dAnim); check('animated style takes the style-anim hold (continuous)', dAnim?.mode === 'continuous' && dAnim.holds.includes('style-anim'), dAnim);
const animFrames = await countFrames(2_000); const animFrames = await countFrames(2_000);
check('animated style keeps frames flowing (≥50% rAF)', animFrames.renders >= animFrames.rafs * 0.5, animFrames); check('animated style keeps frames flowing (≥50% rAF)', animFrames.renders >= animFrames.rafs * 0.5, animFrames);
} else { } 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 // Returning to normal keeps detection ON by design (it is the default, and
// detection persists across style switches). Since 2026-08-22 detection holds // 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( await p.waitForFunction(
() => () =>
(window.__godsEyeView && (window.__godsEyeView &&
!document.querySelector('#globe-engine')?.disabled && !document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
) &&
!document.querySelector('.engine-switch-frame') && !document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden')) || document.querySelector('#loading-screen.hidden')) ||
document.querySelector('.engine-switch-retry'), document.querySelector('.engine-switch-retry'),
+6 -2
View File
@@ -229,7 +229,9 @@ await strict.run({}, async (b) => {
window.__qaSlow.at && window.__qaSlow.at &&
!window.__qaSlow.coverGone && !window.__qaSlow.coverGone &&
!document.querySelector('.engine-switch-frame') && !document.querySelector('.engine-switch-frame') &&
!document.querySelector('#globe-engine')?.disabled !document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
)
) { ) {
window.__qaSlow.coverGone = performance.now(); window.__qaSlow.coverGone = performance.now();
observer.disconnect(); observer.disconnect();
@@ -245,7 +247,9 @@ await strict.run({}, async (b) => {
await p.waitForFunction( await p.waitForFunction(
(target) => (target) =>
window.__godsEyeView?.engine?.kind === 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'), !document.querySelector('.engine-switch-frame'),
{ polling: 200, timeout: 60000 }, { polling: 200, timeout: 60000 },
target, target,
+80 -9
View File
@@ -440,10 +440,11 @@ export async function pickEngine(
?.getAttribute('aria-expanded') === 'true', ?.getAttribute('aria-expanded') === 'true',
); );
} }
await page.waitForSelector('#globe-engine:not([disabled])', { // The picker stays focusable while busy: aria-busy marks a switch.
visible: true, await page.waitForSelector(
timeout: 90000, '#globe-engine:not([disabled]):not([aria-busy="true"])',
}); { visible: true, timeout: 90000 },
);
const from = await page.$eval('#globe-engine', (el) => el.value); const from = await page.$eval('#globe-engine', (el) => el.value);
assert.notEqual(from, kind, `the picker already shows ${kind}`); assert.notEqual(from, kind, `the picker already shows ${kind}`);
// The key typed below is `kind`'s first letter: it selects the next option // 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?.disconnect();
window.__qaPickObserver = new MutationObserver(() => { 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; if (disabled) window.__qaPick.busy = true;
else if (window.__qaPick.busy && window.__qaPick.idleAt == null) else if (window.__qaPick.busy && window.__qaPick.idleAt == null)
window.__qaPick.idleAt = performance.now(); window.__qaPick.idleAt = performance.now();
}); });
window.__qaPickObserver.observe(document.body, { window.__qaPickObserver.observe(document.body, {
attributes: true, attributes: true,
attributeFilter: ['disabled'], attributeFilter: ['disabled', 'aria-busy'],
subtree: true, subtree: true,
}); });
const describe = (hit) => const describe = (hit) =>
@@ -523,8 +526,10 @@ export async function pickEngine(
// Whatever the person does next, right after the key (no round trip to // Whatever the person does next, right after the key (no round trip to
// the page in between). // the page in between).
if (afterInput) await afterInput(); 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, { await page.waitForFunction(() => window.__qaPick?.changed, {
timeout: 5000, timeout: 30000,
polling: 50, polling: 50,
}); });
// Headless Chrome opens no menu for a click on the select: the key // Headless Chrome opens no menu for a click on the select: the key
@@ -538,7 +543,9 @@ export async function pickEngine(
await page.waitForFunction( await page.waitForFunction(
() => () =>
(window.__qaPick.busy && (window.__qaPick.busy &&
!document.querySelector('#globe-engine')?.disabled && !document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
) &&
document.querySelector('#loading-screen.hidden') && document.querySelector('#loading-screen.hidden') &&
!document.querySelector('.engine-switch-frame')) || !document.querySelector('.engine-switch-frame')) ||
document.querySelector('.engine-switch-retry'), document.querySelector('.engine-switch-retry'),
@@ -572,7 +579,9 @@ export async function waitForEngine(page, kind, { timeout = 120000 } = {}) {
window.__godsEyeView?.engine?.kind === window.__godsEyeView?.engine?.kind ===
(expected === 'cesium' ? 'cesium' : 'navara') && (expected === 'cesium' ? 'cesium' : 'navara') &&
document.querySelector('#globe-engine')?.value === expected && 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('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'), document.querySelector('#loading-screen.hidden'),
{ timeout }, { timeout },
@@ -837,3 +846,65 @@ export function createStrict({
}; };
return strict; 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({ window.__qaHidden.shown.push({
state: document.visibilityState, state: document.visibilityState,
at: performance.now(), at: performance.now(),
busy: !!document.querySelector('#globe-engine')?.disabled, busy: Boolean(
document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
),
),
}), }),
); );
}, kind); }, kind);
@@ -69,7 +73,9 @@ await strict.run({}, async (b) => {
await p.waitForFunction( await p.waitForFunction(
(kind) => (kind) =>
window.__godsEyeView?.engine?.kind === 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('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'), document.querySelector('#loading-screen.hidden'),
{ polling: 250, timeout: 60000 }, { 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 // 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 // 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 // switch back builds a new engine on the same session that draws a
@@ -21,7 +21,8 @@ import {
startSession, startSession,
} from './qa-strict-session.mjs'; } 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 base = localPreview(process.argv[2]);
const other = (kind) => (kind === 'navara' ? 'cesium' : 'navara'); const other = (kind) => (kind === 'navara' ? 'cesium' : 'navara');
const strict = createStrict({ origin: base.origin, layers: [] }); const strict = createStrict({ origin: base.origin, layers: [] });
@@ -41,8 +42,9 @@ await strict.run({}, async (b) => {
console.log('PASS', name); console.log('PASS', name);
}; };
const pick = async (kind) => { const pick = async (kind) => {
await strict.pick(p, kind); const { at } = await strict.pick(p, kind);
await waitForEngine(p, kind); await waitForEngine(p, kind);
return at;
}; };
// The running engine is the one remembered under its kind (warm), and // The running engine is the one remembered under its kind (warm), and
// the context of `kind`'s remembered engine is lost or not. // the context of `kind`'s remembered engine is lost or not.
@@ -77,17 +79,17 @@ await strict.run({}, async (b) => {
await pick(parked); await pick(parked);
await rememberEngine(p); await rememberEngine(p);
} }
await pick(running); const parkedAt = await pick(running);
await rememberEngine(p); await rememberEngine(p);
// A switch back in time is warm. // A switch back in time is warm.
await wait(PARKED_MS / 3); await wait(1000);
await pick(parked); const backAt = await pick(parked);
const early = await engines(parked); const early = await engines(parked);
await assertSession(p, parked, `a quick switch back to ${parked}`); await assertSession(p, parked, `a quick switch back to ${parked}`);
contract( contract(
`a switch back to ${parked} within the delay reuses the parked engine`, `a switch back to ${parked} within the delay reuses the parked engine`,
early.warm && early.lost === false, early.warm && early.lost === false,
early, { ...early, parkedMs: Math.round(backAt - parkedAt) },
); );
// Parked past the delay: released, and the next switch back is cold. // Parked past the delay: released, and the next switch back is cold.
await pick(running); await pick(running);
+5 -1
View File
@@ -89,7 +89,11 @@ await strict.run({}, async (b) => {
window.__qaSteps.push({ window.__qaSteps.push({
step, step,
engine: window.__godsEyeView?.engine?.kind ?? null, 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; window.__qaMark = mark;
}); });
+30 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { quantile, slope } from './qa-strict-session.mjs'; 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', () => { test('slope is the least-squares growth per step', () => {
assert.equal(slope([[0, 5]]), 0); 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, 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). // place every frame; it never empties and refills a collection).
// --baseline records the pre-contract build, whose PointPrimitive // --baseline records the pre-contract build, whose PointPrimitive
// collections sat in scene.primitives and were rebuilt by removeAll. // collections sat in scene.primitives and were rebuilt by removeAll.
const engine = window.__godsEyeView.engine; if (!baseline) {
const createMoverSet = engine.movers.createSet.bind(engine.movers); const engine = window.__godsEyeView.engine;
engine.movers.createSet = (options) => { const createMoverSet = engine.movers.createSet.bind(engine.movers);
if (/^traffic-(fill|outline)$/.test(options?.id ?? '')) rebuilds++; engine.movers.createSet = (options) => {
return createMoverSet(options); if (/^traffic-(fill|outline)$/.test(options?.id ?? '')) rebuilds++;
}; return createMoverSet(options);
};
}
function discoverCollections() { function discoverCollections() {
if (!baseline) return; if (!baseline) return;
for (let i = 0; i < scene.primitives.length; i++) { for (let i = 0; i < scene.primitives.length; i++) {
+1 -1
View File
@@ -201,7 +201,7 @@ export async function sampleTransitPixels(page, palette) {
?.getDetectionDiagnostics?.() ?.getDetectionDiagnostics?.()
?.calloutRects?.filter((r) => r.alpha > 0.01) || []; ?.calloutRects?.filter((r) => r.alpha > 0.01) || [];
if (state._selectedKey) { if (state._selectedKey) {
const card = overlay?.getOverlayPaintRect( const card = overlay?.getPaintRect(
'transit-selected', 'transit-selected',
state._selectedKey, state._selectedKey,
); );
+2 -3
View File
@@ -8,12 +8,11 @@ export async function runTransitHeadingRegression(base, check) {
base = transitQaBase(base); base = transitQaBase(base);
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
headless: true, headless: true,
executablePath: executablePath: chromeExecutablePath(),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: [ args: [
'--no-sandbox', '--no-sandbox',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
'--use-angle=metal', ...(process.platform === 'darwin' ? ['--use-angle=metal'] : []),
'--enable-gpu', '--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 }); await mkdir(shots, { recursive: true });
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
headless: true, headless: true,
executablePath: executablePath: chromeExecutablePath(),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: [ args: [
'--use-angle=metal', ...(process.platform === 'darwin' ? ['--use-angle=metal'] : []),
'--enable-gpu', '--enable-gpu',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--disable-renderer-backgrounding',
+1 -1
View File
@@ -1657,7 +1657,7 @@ try {
const t0 = performance.now(); const t0 = performance.now();
const tick = () => { const tick = () => {
const entry = state._vehicles.get(key); const entry = state._vehicles.get(key);
const rect = overlay.getOverlayPaintRect('transit-selected', key); const rect = overlay.getPaintRect('transit-selected', key);
if (entry?.marker) { if (entry?.marker) {
const view = scene.camera.viewMatrix; const view = scene.camera.viewMatrix;
const proj = scene.camera.frustum.projectionMatrix; const proj = scene.camera.frustum.projectionMatrix;
+15 -31
View File
@@ -1,6 +1,12 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { fromDegrees } from '../src/geo/cartesian3.js'; 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 { installTransitFixture } from './fixtures/layers/transit.mjs';
import { import {
combinedPayloads, combinedPayloads,
@@ -128,36 +134,14 @@ await strict.run({}, async (b) => {
); );
// A click on empty ground (nothing picked around it, nothing covering it). // A click on empty ground (nothing picked around it, nothing covering it).
const clickEmpty = async () => { const clickEmpty = async () => {
const handle = await p.waitForFunction( await clickEmptyGround(p, {
() => { left: 440,
const { engine } = window.__godsEyeView; top: 230,
const canvas = engine.host.canvas; right: 840,
const box = canvas.getBoundingClientRect(); bottom: 600,
for (let y = 230; y < 600; y += 9) step: 9,
for (let x = 440; x < 840; x += 9) { radius: 8,
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 p.waitForFunction( await p.waitForFunction(
() => () =>
!window.qaUrban !window.qaUrban
+31 -1
View File
@@ -308,7 +308,11 @@ await strict.run({}, async (b) => {
engine: window.__godsEyeView?.engine?.kind ?? null, engine: window.__godsEyeView?.engine?.kind ?? null,
picker: document.querySelector('#globe-engine')?.value ?? null, picker: document.querySelector('#globe-engine')?.value ?? null,
focus: document.activeElement?.id ?? 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')), frame: Boolean(document.querySelector('.engine-switch-frame')),
loading: document.querySelector('#loading-screen')?.className ?? null, loading: document.querySelector('#loading-screen')?.className ?? null,
})); }));
@@ -343,6 +347,9 @@ await strict.run({}, async (b) => {
let selected = null; let selected = null;
for (const { kind, via, click: id } of LEGS) { for (const { kind, via, click: id } of LEGS) {
if (via) { if (via) {
const poseBefore = await p.evaluate(() =>
window.__godsEyeView.engine.camera.pose(),
);
const reads = { ...fixture.asked }; const reads = { ...fixture.asked };
const sighting = byId.get(selected); const sighting = byId.get(selected);
if (via === 'keyboard') { if (via === 'keyboard') {
@@ -387,6 +394,29 @@ await strict.run({}, async (b) => {
console.log('PASS', kind, 'picker click closed the card'); console.log('PASS', kind, 'picker click closed the card');
selected = null; 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(); await frame();
} }
+54 -27
View File
@@ -2014,8 +2014,13 @@ async function main() {
await mil.update(v); await mil.update(v);
return { return {
groundSnap, milGroundSnap, detectable, airSnap, groundAgainSnap, groundSnap, milGroundSnap, detectable, airSnap, groundAgainSnap,
droppedGone: !snap('aaa066'), // Removed contacts can leave hidden slots in an engine's sprite pool.
milDroppedGone: !snap('bbb166'), 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'); 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; } try { p = coll.get(i); } catch { continue; }
if (!p) continue; if (!p) continue;
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(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); walk(v.scene.primitives);
return found; 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 // (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)', skip('ground-3d: grounded planes are model-eligible and modeled (both layers)',
`independent GLB control unavailable (${glbControlDetail}); grounded GLB readiness is not observable`); `independent GLB control unavailable (${glbControlDetail}); grounded GLB readiness is not observable`);
} else { } 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, 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) { if (g3dModelsUp) {
@@ -2767,8 +2799,10 @@ async function main() {
}); });
await nextFrames(3); await nextFrames(3);
const angDiff = (a, b) => Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b))); const angDiff = (a, b) => Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b)));
const probe = async (id, course) => { const probe = async (layerId, id, course) => {
const bb = findBB(id); // 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` }; if (!bb || !bb.show) return { error: `${id} missing/hidden` };
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id)); 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 flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0; const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0;
return { return {
flights: await probe('aaa002', flightCourse), flights: await probe('flights', 'aaa002', flightCourse),
military: await probe('bbb101', militaryCourse), 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}` 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 // (a) FLEET: retain the adversarial flags until this contact has passed
// through its installed fleet update and that frame has completed. // through its installed fleet update and that frame has completed.
const findFleetModel = (id) => { // The layer's own model handle: the engine's Model is drawn from it,
let found = null; // and a flag planted on the engine's object never reaches the layer.
const walk = (coll) => { const fleetModelOf = (id) => fl.testing._aircraftHandlesForTest(id).model;
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;
};
fl.setParams({ models3d: true }); fl.setParams({ models3d: true });
const fleetDeadline = Date.now() + 20000; const fleetDeadline = Date.now() + 20000;
let fleetModel = null; let fleetModel = null;
while (Date.now() < fleetDeadline && !(fleetModel = findFleetModel('aaa097'))) { while (Date.now() < fleetDeadline && !(fleetModel = fleetModelOf('aaa097'))) {
await window.__dfSettle(500); await window.__dfSettle(500);
} }
if (!fleetModel) { if (!fleetModel) {
@@ -3849,7 +3872,7 @@ async function main() {
return; return;
} }
const currentBb = window.__dfFindBB('aaa097'); const currentBb = window.__dfFindBB('aaa097');
const currentModel = findFleetModel('aaa097'); const currentModel = fleetModelOf('aaa097');
sample = { sample = {
fleetTickCompleted: true, fleetTickCompleted: true,
fleetFrame: v.scene.frameState.frameNumber, fleetFrame: v.scene.frameState.frameNumber,
@@ -3859,7 +3882,7 @@ async function main() {
fleetSeeded: seededFleet, fleetSeeded: seededFleet,
fleetH: currentBb ? window.__dfCarto(currentBb.position).h : null, 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'; || fl.getTrackedInfo()) sample.error = 'fleet loading fixture changed before its completed frame';
}); });
armed = true; armed = true;
@@ -4032,13 +4055,17 @@ async function main() {
const taxiMeshM = gfns?.cachedMeshFloor?.(taxiCarto.lat, taxiCarto.lon) ?? null; const taxiMeshM = gfns?.cachedMeshFloor?.(taxiCarto.lat, taxiCarto.lon) ?? null;
const heldOwns = ns._driveFleetModelHandoffForTest({ icao24: holdIcao, position: taxiPos, course: 90 }); const heldOwns = ns._driveFleetModelHandoffForTest({ icao24: holdIcao, position: taxiPos, course: 90 });
const heldH = window.__dfModelHeight(holdIcao); 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 // 3. ~385 m out the memory stops describing anywhere this contact has
// been. It is released rather than stretched, and the gate takes over. // 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 farPos = Cesium.Cartesian3.fromDegrees(base.lon + 0.004, base.lat, base.h);
const releasedOwns = ns._driveFleetModelHandoffForTest({ icao24: holdIcao, position: farPos, course: 90 }); 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 }); fl.setParams({ models3d: false });
await window.__dfSettle(300); 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 } = {}) { export function createNavaraAnnotationRenderer(engine, views, { screen } = {}) {
const world = createNavaraWorldAnnotationRenderer(engine, views); const world = createNavaraWorldAnnotationRenderer(engine, views);
const renderer = createHybridAnnotationRenderer(null, { const renderer = createHybridAnnotationRenderer(null, {
engine,
world, world,
screenScene: screenSceneFromEngine(engine), screenScene: screenSceneFromEngine(engine),
screen, screen,
@@ -12,6 +12,7 @@ import {
createNavaraWorldAnnotationRenderer, createNavaraWorldAnnotationRenderer,
drapedParts, drapedParts,
} from './navaraAnnotationRenderer.js'; } from './navaraAnnotationRenderer.js';
import { createSceneServices } from '../data/sceneServices.js';
import { screenSceneFromEngine } from './screenScene.js'; import { screenSceneFromEngine } from './screenScene.js';
for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) { 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(); engine.fake.frame();
assert.equal(live().length, 0); 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 * 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 * 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 * and sprites) reads back as a point near the camera, the nearer the lower
* pick is a miss; the ray's globe hit answers instead. * 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) { function depthAtCamera(engine, screen, hit) {
const camera = engine.camera.livePosition; const camera = engine.camera.livePosition;
const ground = engine.camera.pickEllipsoid(screen); let ground = engine.picker.pickGlobe(screen);
const reference = ground ? geo.cartesian3.distance(ground, camera) : 0; 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); 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); const hit = engine.picker.pickPosition(screen);
return hit && !depthAtCamera(engine, screen, hit) ? hit : undefined; return hit && !depthAtCamera(engine, screen, hit) ? hit : undefined;
}, },
() => engine.camera.pickEllipsoid(screen),
() => engine.picker.pickGlobe(screen), () => engine.picker.pickGlobe(screen),
() => engine.camera.pickEllipsoid(screen),
]; ];
for (const stage of stages) { for (const stage of stages) {
let cart = null; 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 { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js'; import { createLayerCatalog } from './catalog.js';
import { rendererFor } from './layers/renderers.js'; import { rendererFor } from './layers/renderers.js';
import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js'; import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createApplicationFlights } from './layers/flights.js'; import { createApplicationFlights } from './layers/flights.js';
@@ -34,7 +33,7 @@ import { createApplicationGhostMaps } from './layers/ghostMaps.js';
import { createApplicationDroneAirspace } from './layers/droneAirspace.js'; import { createApplicationDroneAirspace } from './layers/droneAirspace.js';
import { createApplicationWildlifeSightings } from './layers/wildlifeSightings.js'; import { createApplicationWildlifeSightings } from './layers/wildlifeSightings.js';
import { createApplicationCables } from './layers/submarineCables.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 { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureSessionLayer } from '../layers/infrastructure/index.js'; import { createInfrastructureSessionLayer } from '../layers/infrastructure/index.js';
import { createInfrastructureLayers } from '../data/infrastructure.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) => { const sessionModel = (id, create) => {
if (!layerSession) return create(); if (!layerSession) return create();
const models = (layerSession.models ??= new Map()); const models = (layerSession.models ??= new Map());
if (!models.has(id)) models.set(id, create()); if (!models.has(id)) models.set(id, create());
return models.get(id); return models.get(id);
}; };
const flights = once(() => const flights = sessionModel('flights', () =>
sessionModel('flights', () => createApplicationFlights({
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, surface,
source: sources.flights, // QA fixture receivers (?qaFeed=local-adsb:fixture, dev only).
militaryRegistry, ...(sources.localAdsbFeeds ? { feeds: sources.localAdsbFeeds } : {}),
resolveAsset, enrichment: sources.flights,
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}), }),
), ),
); sessionModel('earthquakes', () =>
const military = once(() => createApplicationEarthquakes({ source: sources.earthquakes }),
sessionModel('military', () => ),
createApplicationMilitary({ 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, surface,
source: sources.military, nativeRenderer: (engine) => rendererFor('street-level', engine),
militaryRegistry, sources: { mapillary: sources.mapillary },
resolveAsset, providers: streetLevelProviders,
}), }),
), ),
); satellites,
const vessels = once(() => sessionModel('rocket-launches', () =>
sessionModel('ais-live-vessels', () => createApplicationLaunches({
createApplicationVessels({ source: sources.launches,
source: sources.vessels, satellites: satellites,
options: vesselOptions,
}), }),
), ),
); sessionModel('traffic', () =>
const installations = once(() => createApplicationTraffic({
sessionModel('military-installations', () => source: sources.traffic,
createApplicationInstallations({
surface, surface,
source: sources.installations,
}), }),
), ),
); sessionModel('cctv', () =>
const satellites = once(() => createApplicationCctv({ surface, source: sources.cctv }),
sessionModel('satellites', () => ),
createApplicationSatellites({ sessionModel('radio', () =>
source: sources.satellites, 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,
}), }),
), ),
); sessionModel('recent-imagery', () =>
// Catalog order: [layer id(s), construction]. Only the layers the createApplicationRecentImagery({
// engine can show are constructed (renderers.js). source: sources['recent-imagery'],
const entries = [ }),
[ ),
'bhote-koshi-2026', sessionModel('osm-places', () => createApplicationOsmPlaces()),
() => vessels,
sessionModel('bhote-koshi-2026', () => installations,
createBhoteKoshiEventLayer({ sessionModel('military-awareness', () =>
createNativeRenderer: (engine) => createApplicationAwareness({
rendererFor('bhote-koshi-2026', engine), flights: flights,
}), military: military,
), vessels: vessels,
], installations: installations,
[ }),
'bhote-koshi-locator', ),
() => sessionModel('wind', () =>
sessionModel('bhote-koshi-locator', () => createWindLayer({
createBhoteKoshiLocatorLayer({ feed: sources.wind,
boundaryResolver: nepalBoundaryResolver, clock: weatherClock,
createNativeRenderer: (engine, data) => createNativeRendering: (engine, options) =>
rendererFor('bhote-koshi-locator', engine, { data }), rendererFor('wind', engine, options),
}), }),
), ),
], ...['weather-radar', 'weather-satellite', 'weather-lightning'].map((id) =>
['flights', flights], sessionModel(id, () =>
['military', military], createWeatherLayer({
[ feed: sources.weather,
'local-adsb', id,
() => clock: weatherClock,
sessionModel('local-adsb', () => createNativeRendering: (engine, options) =>
createApplicationLocalAdsb({ rendererFor(id, engine, options),
surface, }),
// QA fixture receivers (?qaFeed=local-adsb:fixture, dev only). ),
...(sources.localAdsbFeeds ),
? { feeds: sources.localAdsbFeeds } sessionModel('weather-cyclones', () =>
: {}), createApplicationCyclones({
enrichment: sources.flights, feed: sources.cyclones,
displayParams: () => flights().getParams(), createNativeRendering: (engine, options) =>
...(resolveAsset ? { resolveAsset } : {}), rendererFor('weather-cyclones', engine, options),
}), }),
), ),
], ...sessionModel('osm-overlays', () => createOsmOverlayLayers()),
[ ...sessionModel('local-infrastructure', () =>
'earthquakes', createInfrastructureLayers(localGeoJsonServices, (config, services) =>
() => createInfrastructureSessionLayer(
sessionModel('earthquakes', () => config,
createApplicationEarthquakes({ source: sources.earthquakes }), services,
), (engine, descriptor, ops) =>
], rendererFor(config.id, engine, {
[ config: descriptor,
'air-quality', services: ops,
() =>
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('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); const catalog = createLayerCatalog(layers, metadata);
return Object.freeze({ return Object.freeze({
...catalog, ...catalog,
-2
View File
@@ -46,8 +46,6 @@ export async function createApplicationControls({
const styleManager = new Controls(viewer, { const styleManager = new Controls(viewer, {
services: { services: {
...sharedServices, ...sharedServices,
// The catalog's layers; the ones this engine cannot show (Navara)
// are empty stand-ins.
...catalogControlServices(catalog), ...catalogControlServices(catalog),
}, },
requestServices: operations.requests, 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']); 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. */ /** The engine a choice runs: both Navara choices are the Navara engine. */
export const engineOfChoice = (choice) => export const engineOfChoice = (choice) =>
choice === 'cesium' ? 'cesium' : 'navara'; choice === 'cesium' ? 'cesium' : 'navara';
@@ -21,12 +24,16 @@ const known = (value) => {
/** /**
* The globe choice for this page: `?engine=cesium|navara|navara-hd`, else * 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. * 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'} * @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')); const fromQuery = known(new URLSearchParams(search).get('engine'));
if (fromQuery) return fromQuery; if (fromQuery) return fromQuery;
let stored = null; let stored = null;
@@ -35,5 +42,5 @@ export function resolveEngineKind({ search = '', storage = null } = {}) {
} catch { } catch {
// Storage can be blocked (private mode, sandboxed frames). // 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, entry,
enabled: true, enabled: true,
durable: true, durable: true,
visit: false,
}); });
resumeSession(session); resumeSession(session);
assert.deepEqual(calls, ['suspend', 'viewport', 'resume']); 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 { rendererFor } from './renderers.js';
import * as readouts from '../../hudReadouts.js'; import * as readouts from '../../hudReadouts.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationVessels({ export function createApplicationVessels({
source, source,
options = {}, options = {},
-5
View File
@@ -9,11 +9,6 @@ import * as picking from '../../data/pickRegistry.js';
import { keyholeLabelAlphaFromGeometry } from '../../celestialRing.js'; import { keyholeLabelAlphaFromGeometry } from '../../celestialRing.js';
import { refreshTrackedReadout } from '../../data/trackedReadout.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({ export function createApplicationAlpr({
surface, surface,
source, source,
-5
View File
@@ -4,11 +4,6 @@ import * as render from '../../renderGovernor.js';
import * as picking from '../../data/pickRegistry.js'; import * as picking from '../../data/pickRegistry.js';
import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.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 }) { export function createApplicationBikeshare({ source }) {
return createBikeshareLayer({ return createBikeshareLayer({
source, source,
-5
View File
@@ -8,11 +8,6 @@ import * as picking from '../../data/pickRegistry.js';
import * as focus from '../../data/focusDeemphasis.js'; import * as focus from '../../data/focusDeemphasis.js';
import * as render from '../../renderGovernor.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({ export function createApplicationCctv({
surface, surface,
source, source,
-1
View File
@@ -12,7 +12,6 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
import * as recession from '../../data/aircraftRecession.js'; import * as recession from '../../data/aircraftRecession.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationFlights({ export function createApplicationFlights({
surface, surface,
source, source,
-1
View File
@@ -12,7 +12,6 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
import * as recession from '../../data/aircraftRecession.js'; import * as recession from '../../data/aircraftRecession.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationMilitary({ export function createApplicationMilitary({
surface, surface,
source, source,
-5
View File
@@ -6,11 +6,6 @@ import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js'; import * as context from '../../data/contextStore.js';
import * as picking from '../../data/pickRegistry.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 }) { export function createApplicationInstallations({ surface, source }) {
const { groundFloor: ground, anchors } = surface; const { groundFloor: ground, anchors } = surface;
return createInstallationsLayer({ return createInstallationsLayer({
+5 -1
View File
@@ -31,6 +31,9 @@ import { satelliteFixture } from '../../bench/satelliteFixture.js';
import { createSatelliteSource } from '../../layers/satellites/source.js'; import { createSatelliteSource } from '../../layers/satellites/source.js';
import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.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({ const KINDS = Object.freeze({
flights: new Set(['synthetic']), flights: new Set(['synthetic']),
military: new Set(['synthetic']), military: new Set(['synthetic']),
@@ -49,7 +52,8 @@ export function parseQaFeed(search = '') {
const count = rawCount === undefined ? null : Number(rawCount); const count = rawCount === undefined ? null : Number(rawCount);
if ( if (
!KINDS[layer]?.has(kind) || !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( throw new TypeError(
`qaFeed: unknown entry "${item}" (flights:synthetic[:count], military:synthetic[:count], vessels:fixture, satellites:fixture, launches:fixture, local-adsb:fixture)`, `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); 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 globe from '../../celestialRing.js';
import * as render from '../../renderGovernor.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({ export function createApplicationRadio({
surface, surface,
source, source,
-1
View File
@@ -4,7 +4,6 @@ import * as geometry from '../../celestialRing.js';
import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js'; import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationLaunches({ export function createApplicationLaunches({
source, source,
satellites, satellites,
-1
View File
@@ -8,7 +8,6 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
import * as layerState from '../../data/layerState.js'; import * as layerState from '../../data/layerState.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationSatellites({ export function createApplicationSatellites({
source, source,
// Test seam: the renderer factory (default: the engine's, from the // 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 * as render from '../../renderGovernor.js';
import { rendererFor } from './renderers.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 }) { export function createApplicationTraffic({ source, surface }) {
return createTrafficLayer({ return createTrafficLayer({
source, source,
+5 -1
View File
@@ -7,7 +7,7 @@
// - `viewer()`: a Cesium globe is about to start: the SDK and the widget // - `viewer()`: a Cesium globe is about to start: the SDK and the widget
// styles; // styles;
// - `sdk()`: a feature that still builds on Cesium's entity model (the Nepal // - `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({ export function createCesiumLoader({
document = globalThis.document, document = globalThis.document,
globals = globalThis, globals = globalThis,
@@ -45,6 +45,9 @@ export function createCesiumLoader({
const ready = () => const ready = () =>
!script || !script ||
Object.getOwnPropertyDescriptor(globals, 'Cesium')?.value != null; 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 = () => { const sdk = () => {
if (ready()) return Promise.resolve(); if (ready()) return Promise.resolve();
sdkPending ??= load('script', `${root()}Cesium.js`).catch((error) => { sdkPending ??= load('script', `${root()}Cesium.js`).catch((error) => {
@@ -54,6 +57,7 @@ export function createCesiumLoader({
return sdkPending; return sdkPending;
}; };
const styles = () => { const styles = () => {
if (preloaded) return Promise.resolve();
stylesPending ??= load('link', `${root()}Widgets/widgets.css`).catch( stylesPending ??= load('link', `${root()}Widgets/widgets.css`).catch(
(error) => { (error) => {
stylesPending = null; 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); 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 () => { test('a failed Cesium asset load can be retried and preserves the configured base', async () => {
const { elements, globals, loader } = fixture({ const { elements, globals, loader } = fixture({
CESIUM_BASE_URL: '/custom/', 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 { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js';
import { LAYER_MANIFEST } from './layers/renderers.js'; import { LAYER_MANIFEST } from './layers/renderers.js';
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js'; import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js';
import { localizeLayerName } from '../ui/layerNames.js';
/** The Navara globe module, loaded on demand. */ /** The Navara globe module, loaded on demand. */
const loadNavaraGlobeModule = async () => const loadNavaraGlobeModule = async () =>
@@ -26,8 +27,10 @@ export function navaraModeNotice(manifest = LAYER_MANIFEST) {
return Object.freeze({ return Object.freeze({
label: t('Experimental Navara engine'), label: t('Experimental Navara engine'),
detail: names.length detail: names.length
? `Data layers: ${names.join(', ')}` ? t('Data layers: {layers}', {
: 'No registered data 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 * 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 * 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 * governor on the engine, and this engine's own parts: the daylight of a
* restored view and the engine notice. * restored view and the engine notice.
@@ -115,8 +118,6 @@ export function navaraToolBackend({ engine }) {
drawn drawn
? { engine, renderer: createNavaraAnnotationRenderer(engine, views) } ? { engine, renderer: createNavaraAnnotationRenderer(engine, views) }
: null, : null,
// The session's tool on the engine's input and preview.
draw: () => ({ engine }),
recentImagery: () => ({ engine }), recentImagery: () => ({ engine }),
attach({ styleManager }, { signal, defer }) { attach({ styleManager }, { signal, defer }) {
void styleManager.initialRestorePromise?.then(() => { void styleManager.initialRestorePromise?.then(() => {
+4 -5
View File
@@ -272,7 +272,6 @@ function cesiumToolBackend({ engine, tileset }) {
viewer, viewer,
startupChrome: {}, startupChrome: {},
board: () => ({ viewer, engine, tileset }), board: () => ({ viewer, engine, tileset }),
draw: () => ({ engine }),
// The tileset lets the layer drape while the globe is hidden. // The tileset lets the layer drape while the globe is hidden.
recentImagery(layer) { recentImagery(layer) {
layer.attachTileset(tileset); layer.attachTileset(tileset);
@@ -353,7 +352,7 @@ async function attachEngineTools(
{ layerSession, placeSearch, operations }, { layerSession, placeSearch, operations },
defer, 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. // Import draws through the globe contract, the same on every engine.
attachGeoImport( attachGeoImport(
{ engine }, { 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 * 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 * ends an open sketch and keeps the chosen shape and colour. Returns the
* tool (null on a page without it or without the board). * tool (null on a page without it or without the board).
*/ */
function attachDraw( function attachDraw(
drawScene, engine,
{ layerSession, annotations, styleManager }, { layerSession, annotations, styleManager },
defer, defer,
) { ) {
@@ -500,7 +499,7 @@ function attachDraw(
styleManager, styleManager,
defer, defer,
}); });
drawTool?.attachScene({ ...drawScene(), navigation: styleManager }); drawTool?.attachScene({ engine, navigation: styleManager });
defer(() => drawTool?.detachScene()); defer(() => drawTool?.detachScene());
return drawTool; return drawTool;
} }
+6 -2
View File
@@ -410,6 +410,7 @@ export class CelestialRing {
viewer, viewer,
{ enabled = true, onAutoDisable = null, store, engine = null } = {}, { enabled = true, onAutoDisable = null, store, engine = null } = {},
) { ) {
this._engine = engine ?? (viewer?.kind ? viewer : null);
this._engineView = viewer?.kind ? createCelestialView(viewer) : null; this._engineView = viewer?.kind ? createCelestialView(viewer) : null;
this.viewer = this._engineView?.view ?? viewer; this.viewer = this._engineView?.view ?? viewer;
this._projector = engine?.projector ?? null; this._projector = engine?.projector ?? null;
@@ -603,7 +604,7 @@ export class CelestialRing {
this._focusInProgress = false; this._focusInProgress = false;
this.viewer.scene.requestRender?.(); this.viewer.scene.requestRender?.();
}; };
flyCelestialViewer(this.viewer, { const flight = {
destination: geo.cartesian3.fromRadians( destination: geo.cartesian3.fromRadians(
cartographic.longitude, cartographic.longitude,
cartographic.latitude, cartographic.latitude,
@@ -620,7 +621,10 @@ export class CelestialRing {
finishFocus(); finishFocus();
if (!this.isGlobeFullyVisible()) this._autoDisable(); if (!this.isGlobeFullyVisible()) this._autoDisable();
}, },
}); };
if (this._engine)
this._engine.camera.flyTo({ ...flight, easing: 'cubicInOut' });
else flyCelestialViewer(this.viewer, flight);
return true; 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 * Free GPU objects and the canvas's WebGL context: browsers cap live contexts.
* rebuilt on every globe switch, and browsers cap live contexts, so a
* destroyed controller must not leave its context for garbage collection.
*/ */
releaseRenderer() { releaseRenderer() {
const gl = this.gl; 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 // The Cyber HUD sonar's contact treatment on the globe engine, which draws
// it (render.setContactSonar, the `contactSonar` capability: Navara's // 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 // 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 // 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 // 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) => const contactCount = (engine) =>
engine.movers engine.movers.list().reduce((count, set) => count + set.count, 0);
.list()
.filter((set) => set.kind !== 'model')
.reduce((count, set) => count + set.count, 0);
const UNSUPPORTED_MESSAGE = const UNSUPPORTED_MESSAGE =
'Contact GPU unavailable. Native contacts remain visible.'; 'Contact GPU unavailable. Native contacts remain visible.';
@@ -85,12 +82,13 @@ export function createCyberSonarEngineScene(
if (isCyberSonarActive({ root })) syncCyberSonarAnimation(root); if (isCyberSonarActive({ root })) syncCyberSonarAnimation(root);
engine.render.setContactSonar(cyberSonarEngineState(now(), root)); engine.render.setContactSonar(cyberSonarEngineState(now(), root));
}); });
// Contacts the sweep passes over: the engine's movers, and what its own // Contacts the sweep passes over on screen, as the engine's treatment
// treatment drew (Cesium's tinted models and visible native batches). // 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 = () => { const contactsShown = () => {
if (contactCount(engine)) return true;
const drawn = diagnostics(); 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(() => { const removePostRender = engine.frames.postRender.addEventListener(() => {
// A treatment lost while the scene is parked (Sonar OFF, reduced // 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(); dispose();
assert.equal(root.dataset.cyberSonarGpu, undefined); 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_PATH_RENDER_HOLD = 'bhote-koshi-scene-path-travel';
const SCENE_MEDIA_MIN_PLAYBACK_SECONDS = 6; const SCENE_MEDIA_MIN_PLAYBACK_SECONDS = 6;
const SCENE_MEDIA_EXIT_SECONDS = 0.65; 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 BHOTE_EVENT_ACCENT = WORLD_OVERLAY_STYLE.accent;
const FLOOD_COLOR = () => const FLOOD_COLOR = () =>
Cesium.Color.fromCssColorString('#d8954e').withAlpha(0.68); Cesium.Color.fromCssColorString('#d8954e').withAlpha(0.68);
@@ -1050,6 +1052,9 @@ export function createBhoteKoshiEventLayer({
let _evidenceTimeline = []; let _evidenceTimeline = [];
let _evidenceAnchors = []; let _evidenceAnchors = [];
let _evidenceFrameSlots = []; let _evidenceFrameSlots = [];
let _evidenceEntries = [];
// Evidence anchors at the pack's elevations (no terrain sample).
const _fallbackAnchors = new WeakSet();
let _evidenceVideo = null; let _evidenceVideo = null;
let _evidenceVideoIndex = -1; let _evidenceVideoIndex = -1;
let _evidenceVideoReady = false; let _evidenceVideoReady = false;
@@ -1198,14 +1203,17 @@ export function createBhoteKoshiEventLayer({
: []; : [];
let sampled = null; let sampled = null;
try { try {
sampled = if (_engine && !_viewer) {
_engine && !_viewer const revision = _engine.maps.terrainRevision();
? observations.map(({ lon, lat }) => ({ const heights = await _engine.surface.sampleTerrainMostDetailed(
height: _engine.surface.terrainHeight( observations.map(({ lon, lat }) =>
Cesium.Cartographic.fromDegrees(lon, lat), Cesium.Cartographic.fromDegrees(lon, lat),
), ),
})) );
: await terrainSampler(_viewer, observations, { signal }); // 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) { } catch (error) {
if (error?.name === 'AbortError') throw error; if (error?.name === 'AbortError') throw error;
console.warn( console.warn(
@@ -1217,14 +1225,35 @@ export function createBhoteKoshiEventLayer({
return observations.map((observation, index) => { return observations.map((observation, index) => {
const sampledHeight = Number(sampled?.[index]?.height); const sampledHeight = Number(sampled?.[index]?.height);
const fallbackHeight = Number(observation.fallbackElevationM) || 0; const fallbackHeight = Number(observation.fallbackElevationM) || 0;
return Cesium.Cartesian3.fromDegrees( const anchor = Cesium.Cartesian3.fromDegrees(
observation.lon, observation.lon,
observation.lat, observation.lat,
(Number.isFinite(sampledHeight) ? sampledHeight : fallbackHeight) + 18, (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) { function evidencePositionGetter(index, breadcrumb = false) {
return () => { return () => {
if (!_enabled) return null; if (!_enabled) return null;
@@ -1245,13 +1274,13 @@ export function createBhoteKoshiEventLayer({
_sceneMediaPlaybackCompletedBeatId _sceneMediaPlaybackCompletedBeatId
) )
return null; return null;
return _evidenceAnchors[index] || null; return refineEvidenceAnchor(index);
} }
const activeIndex = activeEvidenceIndex(_progress, _evidenceTimeline); const activeIndex = activeEvidenceIndex(_progress, _evidenceTimeline);
if (breadcrumb ? index >= activeIndex : index !== activeIndex) if (breadcrumb ? index >= activeIndex : index !== activeIndex)
return null; return null;
if (!breadcrumb && index === _embeddedEvidenceIndex) return null; if (!breadcrumb && index === _embeddedEvidenceIndex) return null;
return _evidenceAnchors[index] || null; return refineEvidenceAnchor(index);
}; };
} }
@@ -2448,7 +2477,12 @@ export function createBhoteKoshiEventLayer({
placement: 'above', 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, cohortLimit: 16,
collisionCapacity: 12, collisionCapacity: 12,
moving: false, moving: false,
@@ -3557,6 +3591,7 @@ export function createBhoteKoshiEventLayer({
return; return;
} }
createSplitControl(_event); createSplitControl(_event);
restoreEvidenceEntries();
syncPresentationMode(); syncPresentationMode();
attachCameraInputRelease(); attachCameraInputRelease();
attachEvidenceCardActivation(); attachEvidenceCardActivation();
@@ -3739,6 +3774,7 @@ export function createBhoteKoshiEventLayer({
_corridorPositionCache = { visibleCount: -1, positions: [] }; _corridorPositionCache = { visibleCount: -1, positions: [] };
_evidenceTimeline = []; _evidenceTimeline = [];
_evidenceAnchors = []; _evidenceAnchors = [];
_evidenceEntries = [];
_cinematicKeyframes = []; _cinematicKeyframes = [];
for (const slot of _evidenceFrameSlots) { for (const slot of _evidenceFrameSlots) {
closeFrame(slot.frame); 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; 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 test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { LayerLifecycle } from './lifecycle.js';
import { cancelCameraArrival, ownCameraArrival } from './cameraArrival.js'; import { cancelCameraArrival, ownCameraArrival } from './cameraArrival.js';
test('an arrival owned on the engine yields to a cancel on the engine', () => { 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); cancelCameraArrival(null);
assert.deepEqual(seen, ['old', 'new']); 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 // One imported file (UTILITIES ▸ Import) drawn on the globe contract, the
// same on every engine, from its plain records (src/data/geoImportRecords.js), // same on every engine, from its plain records (src/data/geoImportRecords.js),
// with Cesium's look as the reference: // with Cesium's look as the reference:
// - polygons: one ground 'polygon' set of their fills; a raised polygon // - polygons: ground fills, or meshes and outlines at the vertices' heights;
// (KML absolute or relative) also gets its outline as lines at its
// heights, as Cesium drew it (its fill drapes: the ground has no heights);
// - ground lines: one ground 'corridor' set in pixels, over the fills; // - ground lines: one ground 'corridor' set in pixels, over the fills;
// - lines in the air: one engine line each; // - lines in the air: one engine line each;
// - points: one sprite set per look, each sprite a square centred on its // - 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 // geoImportPins.js); points on the ground draw over the scene and are
// hidden beyond the horizon instead, as Cesium's clamped billboards // hidden beyond the horizon instead, as Cesium's clamped billboards
// ignored the depth test; // 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 // Nothing is pickable: an imported file has no card. Ground heights under
// the points come from engine.surface, sampled again by refreshGround(): // the points come from engine.surface, sampled again by refreshGround():
// the terrain where the globe is drawn, else (3D tiles) the scene drawn // 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 // under a low camera, never an imported point (as Cesium's import kept its
// pins out of height queries). // pins out of height queries).
import earcut from 'earcut';
import * as geo from '../geo/index.js'; import * as geo from '../geo/index.js';
import { normalizeGroundItem } from '../globe/ground.js'; import { normalizeGroundItem } from '../globe/ground.js';
import { composeIcon } from './geoImportPins.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). // drawn now; higher up the terrain answers (Cesium's 250 km).
const CLAMP_TILES_BELOW_M = 250_000; const CLAMP_TILES_BELOW_M = 250_000;
const BATCH = 256; 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; let serial = 0;
// Each engine's imported point sets, left out of each other's ground. // Each engine's imported point sets, left out of each other's ground.
const pointSets = new WeakMap(); 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 flat = (positions) => positions.map(([lon, lat]) => [lon, lat]);
const cartesian = ([lon, lat, height = 0]) => const cartesian = ([lon, lat, height = 0]) =>
geo.cartesian3.fromDegrees(lon, lat, height); geo.cartesian3.fromDegrees(lon, lat, height);
@@ -110,6 +212,9 @@ export async function drawImportedFile(
let removeCull = null; let removeCull = null;
const groundSets = []; const groundSets = [];
const lines = []; const lines = [];
const meshes = [];
const pins = [];
const kinds = { ...records.undrawn };
const layers = []; const layers = [];
const looks = new Map(); const looks = new Map();
let drawn = 0; let drawn = 0;
@@ -120,12 +225,14 @@ export async function drawImportedFile(
removeCull = null; removeCull = null;
for (const set of groundSets.splice(0)) set.destroy(); for (const set of groundSets.splice(0)) set.destroy();
for (const line of lines.splice(0)) line.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 layer of layers.splice(0)) layer.destroy();
for (const look of looks.values()) { for (const look of looks.values()) {
pointSetsOf(engine).delete(look.set); pointSetsOf(engine).delete(look.set);
look.set?.destroy(); look.set?.destroy();
} }
looks.clear(); looks.clear();
pins.length = 0;
} }
try { try {
@@ -133,7 +240,34 @@ export async function drawImportedFile(
const fills = []; const fills = [];
for (const polygon of records.polygons) { for (const polygon of records.polygons) {
let any = false; 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', { const item = checked('polygon', {
rings: polygon.rings.map(flat), rings: polygon.rings.map(flat),
fill: polygon.fill, fill: polygon.fill,
@@ -160,34 +294,47 @@ export async function drawImportedFile(
if (any) drawn++; if (any) drawn++;
} }
for (const overlay of records.overlays) { 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) { if (overlay.url) {
const prepared = await overlayImage(overlay, loadImage, createCanvas);
if (!prepared) {
kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1;
continue;
}
layers.push( layers.push(
engine.imagery.createLayer({ engine.imagery.createLayer({
id: `${prefix}-overlay-${layers.length}`, id: `${prefix}-overlay-${layers.length}`,
source: { source: { kind: 'image', ...prepared },
kind: 'image',
url: overlay.url,
rectangle: overlay.rectangle,
},
// Cesium's transparent image material: at most 0.99. // Cesium's transparent image material: at most 0.99.
alpha: Math.min(0.99, overlay.color[3]), alpha: Math.min(0.99, overlay.color[3]),
show: shown, show: shown,
order: OVERLAY_ORDER, order: OVERLAY_ORDER,
...(raise ? { raise } : {}),
}), }),
); );
drawn++; drawn++;
continue; 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 { west, south, east, north } = overlay.rectangle;
const item = checked('cell', { west, south, east, north }); const item = checked('cell', { west, south, east, north });
if (!item) continue; if (!item) continue;
const { box, turn } = overlayBox(overlay);
const rings = [ const rings = [
[ [
[west, south], [box.west, box.south],
[east < west ? east + 360 : east, south], [box.east, box.south],
[east < west ? east + 360 : east, north], [box.east, box.north],
[west, north], [box.west, box.north],
], ].map(turn),
]; ];
fills.push({ rings, fill: overlay.color }); fills.push({ rings, fill: overlay.color });
drawn++; drawn++;
@@ -261,6 +408,11 @@ export async function drawImportedFile(
rotation: point.icon.rotation ?? 0, 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( await Promise.all(
[...looks.values()].map(async (look) => { [...looks.values()].map(async (look) => {
const url = look.icon.image?.url; const url = look.icon.image?.url;
@@ -279,6 +431,7 @@ export async function drawImportedFile(
image, image,
createCanvas, createCanvas,
ratio: pixelRatio, ratio: pixelRatio,
maxSide,
}); });
look.set = engine.movers.createSet({ look.set = engine.movers.createSet({
id: `${prefix}-pins-${look.depth}-${looks.size}-${++serial}`, 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'); 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); for (const look of looks.values()) writePins(look);
syncCull(); syncCull();
} catch (error) { } catch (error) {
@@ -316,11 +473,30 @@ export async function drawImportedFile(
(engine.imagery?.surface?.() ?? 'globe') !== 'globe' && (engine.imagery?.surface?.() ?? 'globe') !== 'globe' &&
surface.clampToHeightSupported && surface.clampToHeightSupported &&
engine.camera.height() < CLAMP_TILES_BELOW_M; 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. // 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 at = geo.cartographic.fromDegrees(pin.lon, pin.lat, 0);
const terrain = surface.terrainHeightSupported const terrain = surface.terrainHeightSupported
? surface.terrainHeight(at) ? surface.terrainHeight(at)
@@ -331,7 +507,10 @@ export async function drawImportedFile(
? Math.max(0, terrain) ? Math.max(0, terrain)
: terrain : terrain
: (pin.ground ?? 0); : (pin.ground ?? 0);
if (tiles) { if (
tiles &&
inWindow(geo.cartesian3.fromDegrees(pin.lon, pin.lat, ground), size)
) {
const top = surface.clampToHeight( const top = surface.clampToHeight(
geo.cartesian3.fromDegrees(pin.lon, pin.lat, Math.max(0, ground)), geo.cartesian3.fromDegrees(pin.lon, pin.lat, Math.max(0, ground)),
exclude, exclude,
@@ -342,33 +521,33 @@ export async function drawImportedFile(
return ground; return ground;
} }
function settle(context, from = 0, to = Infinity) { function settle(context, from = 0, to = pins.length) {
let at = 0; for (let i = from; i < Math.min(to, pins.length); i++) {
for (const look of looks.values()) const pin = pins[i];
for (const pin of look.pins) { if (pin.height === null) pin.ground = groundUnder(pin, context);
if (at++ < from || at > to) continue; const height = pin.height ?? pin.ground + pin.offset;
if (pin.height === null) pin.ground = groundUnder(pin, context); pin.position = geo.cartesian3.fromDegrees(pin.lon, pin.lat, height);
const height = pin.height ?? pin.ground + pin.offset; pin.horizon = geo.cartesian3.fromDegrees(
pin.position = geo.cartesian3.fromDegrees(pin.lon, pin.lat, height); pin.lon,
pin.horizon = geo.cartesian3.fromDegrees( pin.lat,
pin.lon, height + HORIZON_LIFT_M,
pin.lat, );
height + HORIZON_LIFT_M, }
);
}
} }
function writePins(look) { function writePins(look) {
const { set, pins } = look; const { set, pins } = look;
if (!set) return; if (!set) return;
const culled = look.depth === 'always'; const culled = look.depth === 'always';
const heading = turnsWithCamera(look) ? engine.camera.pose().heading : 0;
pins.forEach((pin, index) => { pins.forEach((pin, index) => {
if (culled) pin.show = engine.projector.horizonVisible(pin.horizon); if (culled) pin.show = engine.projector.horizonVisible(pin.horizon);
pin.drawnRotation = onScreen(pin, heading);
set.set(index, { set.set(index, {
position: pin.position, position: pin.position,
cell: 'icon', cell: 'icon',
color: pin.color, color: pin.color,
rotation: pin.rotation, rotation: pin.drawnRotation,
show: pin.show, show: pin.show,
}); });
}); });
@@ -377,14 +556,18 @@ export async function drawImportedFile(
set.commit(); set.commit();
} }
// Hides the ground points beyond the horizon; true when any show changed. // Hides the ground points beyond the horizon and turns the headed ones
function cull(look) { // with the camera; true when any changed.
function cull(look, heading) {
let changed = false; let changed = false;
look.pins.forEach((pin, index) => { look.pins.forEach((pin, index) => {
const show = engine.projector.horizonVisible(pin.horizon); const show =
if (show === pin.show) return; look.depth !== 'always' || engine.projector.horizonVisible(pin.horizon);
const rotation = onScreen(pin, heading);
if (show === pin.show && rotation === pin.drawnRotation) return;
pin.show = show; pin.show = show;
look.set.set(index, { show }); pin.drawnRotation = rotation;
look.set.set(index, { show, rotation });
changed = true; changed = true;
}); });
if (changed) look.set.commit(); if (changed) look.set.commit();
@@ -392,33 +575,39 @@ export async function drawImportedFile(
} }
function syncCull() { function syncCull() {
const culled = [...looks.values()].filter( const culled = [...looks.values()].filter(
(look) => look.set && look.depth === 'always', (look) => look.set && (look.depth === 'always' || turnsWithCamera(look)),
); );
if (!culled.length || !shown) { if (!culled.length || !shown) {
removeCull?.(); removeCull?.();
removeCull = null; removeCull = null;
return; return;
} }
const turning = culled.some(turnsWithCamera);
const lastCamera = geo.cartesian3.create(Number.NaN, 0, 0); const lastCamera = geo.cartesian3.create(Number.NaN, 0, 0);
let lastHeading = Number.NaN;
removeCull ??= engine.frames.preRender.addEventListener(() => { removeCull ??= engine.frames.preRender.addEventListener(() => {
const live = engine.camera.livePosition; 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); geo.cartesian3.clone(live, lastCamera);
let changed = false; 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(); 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 { return {
report: { drawn, undrawn, kinds: { ...records.undrawn } }, report: { drawn, undrawn, kinds },
setShow(visibleNow) { setShow(visibleNow) {
visibleNow = Boolean(visibleNow); visibleNow = Boolean(visibleNow);
if (destroyed || visibleNow === shown) return; if (destroyed || visibleNow === shown) return;
shown = visibleNow; shown = visibleNow;
for (const set of groundSets) set.show(shown); for (const set of groundSets) set.show(shown);
for (const line of lines) line.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 layer of layers) layer.show(shown);
for (const look of looks.values()) { for (const look of looks.values()) {
look.set?.setCount(shown ? look.count : 0); 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). */ /** Sample the ground under the points again (a map or the terrain changed). */
async refreshGround(signal) { 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) { for (let from = 0; from < total; from += BATCH) {
signal?.throwIfAborted(); signal?.throwIfAborted();
if (destroyed) return; if (destroyed) return;
+222 -3
View File
@@ -4,7 +4,7 @@ import * as Cesium from 'cesium';
import * as geo from '../geo/index.js'; import * as geo from '../geo/index.js';
import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs'; import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs';
import { drawImportedFile } from './geoImportDisplay.js'; import { drawImportedFile } from './geoImportDisplay.js';
import { geoJsonRecords } from './geoImportRecords.js'; import { emptyRecords, geoJsonRecords } from './geoImportRecords.js';
// A 2D context that only records what draws. // A 2D context that only records what draws.
const createCanvas = () => ({ const createCanvas = () => ({
@@ -191,6 +191,8 @@ for (const make of GROUND_HARNESSES) {
viewer.scene.clampToHeightSupported = true; viewer.scene.clampToHeightSupported = true;
} else engine.imagery.surface = () => 'tiles'; } else engine.imagery.surface = () => 'tiles';
engine.camera.height = () => 1000; engine.camera.height = () => 1000;
engine.projector.worldToWindow = () => ({ x: 1, y: 1 });
engine.projector.horizonVisible = () => true;
const excluded = []; const excluded = [];
engine.surface.clampToHeight = (position, exclude) => { engine.surface.clampToHeight = (position, exclude) => {
excluded.push(exclude); excluded.push(exclude);
@@ -270,8 +272,9 @@ for (const make of GROUND_HARNESSES) {
visible: false, visible: false,
}); });
const fill = set(engine.ground.list()[0].id); const fill = set(engine.ground.list()[0].id);
assert.equal(fill.items.length, 2); assert.equal(fill.items.length, 1);
assert.equal(fill.items[0].rings.length, 2); 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.ok(fill.items[0].rings.flat().every((p) => p.length === 2));
assert.equal(fill.shown, false); assert.equal(fill.shown, false);
assert.equal(engine.lines.list().length, 2); 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( displayTest(
'an aborted ground refresh leaves the existing drawing owned', 'an aborted ground refresh leaves the existing drawing owned',
async ({ engine }) => { 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, rectangle,
color, color,
rotation: numberOf(box, 'rotation') ?? 0, 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'); assert.equal(loaded.records.points[0].name, 'QA KML pin');
loaded.revoke(); 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 * `ratio` canvas pixels per CSS pixel. Returns the canvas and the sprite's
* side in CSS pixels. * 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 const natural = image
? { ? {
width: image.naturalWidth || image.width, width: image.naturalWidth || image.width,
@@ -96,7 +99,7 @@ export function composeIcon(icon, { image, createCanvas, ratio = 1 } = {}) {
: undefined; : undefined;
const box = iconBox(icon, natural); const box = iconBox(icon, natural);
// At least one canvas pixel per icon pixel, at most 512 a side. // 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(); const canvas = createCanvas();
canvas.width = Math.max(1, Math.ceil(box.side * k)); canvas.width = Math.max(1, Math.ceil(box.side * k));
canvas.height = canvas.width; 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 {ImportedPolygon[]} polygons
// @property {ImportedOverlay[]} overlays // @property {ImportedOverlay[]} overlays
// @property {Object<string, number>} undrawn What the file has that no // @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 // @typedef {object} ImportedPoint
// @property {number[]} position // @property {number[]} position
@@ -62,6 +64,9 @@
// @property {{ west: number, south: number, east: number, north: number }} rectangle // @property {{ west: number, south: number, east: number, north: number }} rectangle
// @property {number[]} color Multiplies the image; its alpha fades it. // @property {number[]} color Multiplies the image; its alpha fades it.
// @property {number} rotation Degrees counter-clockwise (KML LatLonBox). // @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. */ /** Longest plain-text name kept from a file's features. */
const MAX_IMPORT_NAME_LENGTH = 120; const MAX_IMPORT_NAME_LENGTH = 120;
@@ -162,7 +167,7 @@ export function geoJsonRecords(geojson, color) {
origin: 'bottom', origin: 'bottom',
}, },
}); });
else if (kind === 'LineString') else if (kind === 'LineString' && part.length >= 2)
records.lines.push({ records.lines.push({
name, name,
positions: part.map(position), positions: part.map(position),
@@ -170,7 +175,10 @@ export function geoJsonRecords(geojson, color) {
color: stroke, color: stroke,
widthPx: GEOJSON_STROKE_PX, 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({ records.polygons.push({
name, name,
rings: part.map((ring) => ring.map(position)), rings: part.map((ring) => ring.map(position)),
+31 -19
View File
@@ -404,25 +404,6 @@ export class LayerStateCoordinator {
live, live,
enabled.has(entry.id), 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); const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) { if (origin === LAYER_RESTORE_ORIGINS.share && options) {
for (const trackingKey of Object.values( for (const trackingKey of Object.values(
@@ -445,6 +426,37 @@ export class LayerStateCoordinator {
succeeded: false, 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. // Reserve passive option state before any asynchronous lifecycle work.
// A later explicit params intent then wins on its own lane without // A later explicit params intent then wins on its own lane without
// cancelling or being overwritten by the visibility restore. // 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); const entry = this.layers.get(layerId);
if (!entry) return { intentEpoch: null, promise: Promise.resolve() }; if (!entry) return { intentEpoch: null, promise: Promise.resolve() };
const desiredState = Boolean(shouldEnable); const desiredState = Boolean(shouldEnable);
// A layer leaving ends the camera arrival correction on a globe with a // A layer leaving ends its camera arrival correction.
// viewer (Cesium). if (!desiredState) cancelCameraArrival(this.engine);
if (!desiredState && this.viewer) cancelCameraArrival(this.engine);
if (entry.destroying) { if (entry.destroying) {
return { return {
intentEpoch: null, intentEpoch: null,
+15 -3
View File
@@ -169,10 +169,17 @@ for (const makeHarness of [fakeHarness, createCesiumMoversEngine]) {
height(native.gevDisplayPosition()) < 100, height(native.gevDisplayPosition()) < 100,
'a ready tracked model releases the billboard floor', '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( assert.deepEqual(
publications.at(-1).position(), card,
follow.label.position(), { ...native.gevVisualPosition() },
'the card uses the follow label cache', '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() }; const cached = { ...native.gevDisplayPosition() };
publications.at(-1).position(); publications.at(-1).position();
@@ -185,6 +192,11 @@ for (const makeHarness of [fakeHarness, createCesiumMoversEngine]) {
await frames(); await frames();
const zoomedOut = layer.qaDrawn().find((row) => row.trackedModel)?.model; const zoomedOut = layer.qaDrawn().find((row) => row.trackedModel)?.model;
assert.ok(!zoomedOut?.show, 'zooming out releases the tracked 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') if (layerId === 'flights')
assert.ok( assert.ok(
height(native.gevDisplayPosition()) >= 205.5, height(native.gevDisplayPosition()) >= 205.5,
+1
View File
@@ -191,6 +191,7 @@ export function createTrackedReadout(
return typeof model === 'function' ? model() : model; return typeof model === 'function' ? model() : model;
}, },
gevDisplayPosition: () => (handle.label ?? label).position(), gevDisplayPosition: () => (handle.label ?? label).position(),
gevVisualPosition: () => handle.native?.gevVisualPosition?.(),
}; };
_labelSubjects.set(handle, subject); _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)), ...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 // Card text for the type tokens above. The tokens stay English: TYPE_STYLES
// and tests match on them. // and tests match on them.
const TYPE_DISPLAY_KEYS = Object.freeze({ 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). */ /** The inverse of a rotation-plus-translation matrix (no scale or shear). */
export function inverseTransformation(matrix, result) { 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 matrix0 = matrix[0];
const matrix1 = matrix[1]; const matrix1 = matrix[1];
const matrix2 = matrix[2]; const matrix2 = matrix[2];
-2
View File
@@ -99,8 +99,6 @@ export function scaleToGeodeticSurface(
func = x2 * xMultiplier2 + y2 * yMultiplier2 + z2 * zMultiplier2 - 1.0; 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 = denominator =
x2 * xMultiplier3 * oneOverRadiiSquaredX + x2 * xMultiplier3 * oneOverRadiiSquaredX +
y2 * yMultiplier3 * oneOverRadiiSquaredY + y2 * yMultiplier3 * oneOverRadiiSquaredY +
+6
View File
@@ -203,6 +203,12 @@ export function createCesiumCamera(viewer) {
if (!view?.destination) if (!view?.destination)
throw new TypeError('setView needs a destination'); throw new TypeError('setView needs a destination');
const orientation = cesiumOrientation(view.orientation); 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({ cesium.setView({
destination: cesiumDestination(view.destination), destination: cesiumDestination(view.destination),
orientation, orientation,
Loaded 100 of 276 files, more files were not shown because too many files have changed in this diff. Show more