diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md
index b5d43c662..223917a8a 100644
--- a/DATA_SOURCES.md
+++ b/DATA_SOURCES.md
@@ -5,7 +5,7 @@ God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the
How to read this:
- **The non-permissive datasets are carved out, not omitted.** Some bundled data (e.g. TeleGeography, CC BY-NC-SA) isn't MIT-compatible. Rather than hide it, we **bundle it with a clear license carve-out** so the app works out of the box — but it stays under the provider's terms.
-- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder.
+- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder. **EOX Sentinel-2 cloudless 2020** is also **NonCommercial** (CC BY-NC-SA 4.0): remove that basemap or obtain a commercial licence from EOX.
- **Attribution is shown in-app** and listed here. Keep it intact. The required Google/Cesium credit renders on the on-globe credit line (bottom-left, `#cesium-credits`), and every per-layer credit below is registered into the expandable **"Data attribution"** lightbox on that line (`src/data/dataCredits.js` → `viewer.creditDisplay.addStaticCredit`). Both stay visible in clean-view and recording modes.
- **Bundled model attribution lives beside the model files.** [`public/models/README.md`](public/models/README.md) records each shipped model's creator, source, license, and modification status.
@@ -33,7 +33,7 @@ How to read this:
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
-| **EOX Sentinel-2 cloudless 2020** | Optional satellite basemap in Cesium and Navara. Navara uses daylight for terrain inspection. | [EOX Maps](https://maps.eox.at/) and [imagery licence](https://cloudless.eox.at/) | Sentinel-2 cloudless 2020 by EOX IT Services GmbH (contains modified Copernicus Sentinel data 2020) |
+| **EOX Sentinel-2 cloudless 2020** | Optional satellite basemap in Cesium and Navara. Navara uses daylight for terrain inspection. | [EOX Maps](https://maps.eox.at/); [CC BY-NC-SA 4.0](https://cloudless.eox.at/license-non-commercial). Commercial use requires a commercial licence from EOX. | [EOxCloudless](https://cloudless.eox.at/) by [EOX IT Services GmbH](https://eox.at/) (Contains modified Copernicus Sentinel data 2020) |
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 4defc06c1..fe8afd367 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -52,7 +52,8 @@ resolved dependency inventory remains in `package-lock.json`.
are adapted from `@cesium/engine/Source/Core` (`Math.js`, `Ellipsoid.js`,
`scaleToGeodeticSurface.js`, `Cartographic.js`, `Cartesian2.js`,
`Cartesian3.js`, `Matrix3.js`, `Matrix4.js`, `Quaternion.js`,
- `Transforms.js`) and say so in their first
+ `Transforms.js`, `BoundingSphere.js`, `EllipsoidGeodesic.js`, `JulianDate.js`,
+ `Simon1994PlanetaryPositions.js`) and say so in their first
line. The changes: the classes' static functions became module functions,
vectors, geodetic positions and quaternions became plain-data instances of
module-private constructors (own enumerable fields only, no methods),
diff --git a/build/panel.js b/build/panel.js
index 03610964f..8c1d698d7 100644
--- a/build/panel.js
+++ b/build/panel.js
@@ -6,6 +6,9 @@
* PANEL_BASE next to the app.
*/
+import cesium from 'vite-plugin-cesium';
+import { fileURLToPath } from 'node:url';
+
// The panel loads these paths (src/tools/globePanel.js); a test keeps the
// two in step.
export const PANEL_BASE = '/panel/';
@@ -18,6 +21,27 @@ export function panelBuildConfig(config) {
return {
...config,
base: PANEL_BASE,
+ plugins: (config.plugins ?? []).map((plugin) =>
+ plugin?.name === 'vite-plugin-cesium' ? cesium() : plugin,
+ ),
+ resolve: {
+ ...config.resolve,
+ alias: [
+ // The panel runs Cesium: the Navara mode and Navara's wind mesh
+ // (the panel's only paths to Navara and three) become stubs.
+ {
+ find: /^(?:\.\/|.*\/app\/)navaraMode\.js$|\/globe\/navara\/windFlowMesh\.js$/,
+ replacement: fileURLToPath(
+ new URL('./panelNavara.js', import.meta.url),
+ ),
+ },
+ ...(Array.isArray(config.resolve?.alias)
+ ? config.resolve.alias
+ : Object.entries(config.resolve?.alias ?? {}).map(
+ ([find, replacement]) => ({ find, replacement }),
+ )),
+ ],
+ },
build: {
...config.build,
outDir: PANEL_OUT_DIR,
@@ -26,9 +50,7 @@ export function panelBuildConfig(config) {
cssCodeSplit: false,
rollupOptions: {
...config.build?.rollupOptions,
- // The panel inlines every dynamic import into its one script, the
- // experimental Navara mode included, so the guard that keeps Navara
- // out of the app's initial chunks does not apply here.
+ // The panel is a single chunk; its alias excludes the Navara engine.
plugins: (config.build?.rollupOptions?.plugins ?? []).filter(
(plugin) => plugin?.name !== 'gev-initial-chunk-guard',
),
diff --git a/build/panelNavara.js b/build/panelNavara.js
new file mode 100644
index 000000000..16a2dc4be
--- /dev/null
+++ b/build/panelNavara.js
@@ -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');
+}
diff --git a/build/panelResources.test.mjs b/build/panelResources.test.mjs
new file mode 100644
index 000000000..438226690
--- /dev/null
+++ b/build/panelResources.test.mjs
@@ -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);
+});
diff --git a/build/vite.js b/build/vite.js
index ac00a403a..c3abf2f7d 100644
--- a/build/vite.js
+++ b/build/vite.js
@@ -199,6 +199,20 @@ export function createBrowserViteConfig({
// A production build must not clean the dependency cache a running dev
// server is still serving optimized module URLs from.
...(command === 'build' ? { cacheDir: 'node_modules/.vite-build' } : {}),
+ resolve: {
+ dedupe: ['three'],
+ alias: [
+ {
+ find: /^three$/,
+ replacement: fileURLToPath(
+ new URL(
+ '../node_modules/three/build/three.module.js',
+ import.meta.url,
+ ),
+ ),
+ },
+ ],
+ },
optimizeDeps: {
// First reached through the SDR worker or a dynamic import. Pre-bundle
// them at startup so first use cannot invalidate already-transformed
diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md
index 8ab4bc9f4..292ab077c 100644
--- a/docs/CODE-BOUNDARIES.md
+++ b/docs/CODE-BOUNDARIES.md
@@ -80,10 +80,17 @@ self-package imports, symlinks and unreachable files.
Runtime SDK imports are restricted by directory, including unreachable modules:
`cesium` and `@cesium/*` belong in `src/globe/cesium/`; `three`,
`postprocessing` and `@navaramap/*` belong in `src/globe/navara/`. Package
-subpaths, re-exports and literal dynamic imports obey the same rule. Tests and
-Node test support retain their existing exemptions. The production script
-loader is `src/app/loadCesium.js`; it imports no SDK package and needs no
-exception. There is no blanket exception for layer `render/` directories.
+subpaths, re-exports and literal dynamic imports obey the same rule, and the
+``,
+ 'src/View.vue',
+ ).map(({ line, name }) => `${line}:${name}`),
+ ['4:Color'],
+ );
+});
diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs
index f520e9c76..ab5bcaa17 100644
--- a/scripts/check-import-directions.mjs
+++ b/scripts/check-import-directions.mjs
@@ -8,7 +8,7 @@ import { analyzeModule } from './module-analysis.mjs';
const builtins = new Set(
builtinModules.flatMap((name) => [name, `node:${name}`]),
);
-const code = /\.[mc]?js$/;
+const code = /\.(?:[mc]?js|vue)$/;
const entry = (file) =>
file === 'src/main.js' || file.startsWith('src/standalone/');
const tests = (file) =>
@@ -30,12 +30,6 @@ const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file);
const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/;
// Navara renders through three.js and its postprocessing peer.
const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
-// The modules that load Navara renderers, each through import().
-const NAVARA_RENDERER_LOADERS = new Set([
- 'src/app/navaraMode.js',
- 'src/app/layers/renderers.js',
-]);
-const navaraLayerRenderer = (file) => /\/render\/navara[^/]*\.js$/.test(file);
const renderer = (file) =>
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
@@ -125,6 +119,7 @@ export function checkImportDirections(root) {
try {
analysis = analyzeModule(readFileSync(resolved, 'utf8'), {
dynamic: true,
+ filename: file,
});
} catch (error) {
report(file, error.message);
@@ -188,27 +183,12 @@ export function checkImportDirections(root) {
if (
to.startsWith('src/globe/navara/') &&
!file.startsWith('src/globe/navara/') &&
- !analysis.dynamicImports.includes(specifier)
+ analysis.staticImports.includes(specifier)
)
report(
file,
`The Navara engine loads only through a dynamic import(): ${to}`,
);
- // Like the engine, a layer's Navara renderer reaches the app only
- // through an import() of the Navara mode (src/app/navaraMode.js) or
- // of the layer manifest (src/app/layers/renderers.js).
- if (
- navaraLayerRenderer(to) &&
- !navaraLayerRenderer(file) &&
- !(
- NAVARA_RENDERER_LOADERS.has(file) &&
- analysis.dynamicImports.includes(specifier)
- )
- )
- report(
- file,
- `A Navara layer renderer loads only through the Navara mode's dynamic import(): ${to}`,
- );
if (file.startsWith('src/') && to.startsWith('server/'))
report(file, `Browser imports server: ${to}`);
if (
@@ -322,8 +302,8 @@ export function checkImportDirections(root) {
}
for (const file of files.filter(globeContract)) globe(file);
// Each engine graph stays on its own engine, through helpers too.
- for (const start of files.filter(globeEngine)) {
- const navara = start.startsWith('src/globe/navara/');
+ for (const owner of ['cesium', 'navara']) {
+ const navara = owner === 'navara';
const label = navara ? 'Navara globe engine' : 'Cesium globe engine';
const foreignPackage = navara ? cesiumPackage : navaraPackage;
const foreignEngine = navara ? 'src/globe/cesium/' : 'src/globe/navara/';
@@ -342,7 +322,10 @@ export function checkImportDirections(root) {
else visit(to);
}
};
- visit(start);
+ for (const start of files.filter((file) =>
+ file.startsWith(`src/globe/${owner}/`),
+ ))
+ visit(start);
}
// Common voice controls may use their DOM view, but never a protocol implementation.
const voiceSeen = new Set();
diff --git a/scripts/module-analysis.mjs b/scripts/module-analysis.mjs
index 9871e5f5b..0bcf31488 100644
--- a/scripts/module-analysis.mjs
+++ b/scripts/module-analysis.mjs
@@ -1,4 +1,5 @@
import { parsers } from 'prettier/plugins/babel.mjs';
+import { vueScripts } from './vue-scripts.mjs';
const browserGlobals = new Set([
'window',
@@ -19,9 +20,10 @@ const browserGlobals = new Set([
* Inspect literal module dependencies and browser-platform references without
* executing code. With `dynamic`, also list the import() specifiers apart.
*/
-export function analyzeModule(source, { dynamic = false } = {}) {
+export function analyzeModule(source, { dynamic = false, filename = '' } = {}) {
const imports = [];
const dynamicImports = [];
+ const staticImports = [];
const browser = new Set();
function walk(node, parent, field) {
if (!node || typeof node !== 'object') return;
@@ -41,6 +43,7 @@ export function analyzeModule(source, { dynamic = false } = {}) {
imports.push(node.source.value);
if (node.type === 'ImportExpression')
dynamicImports.push(node.source.value);
+ else staticImports.push(node.source.value);
}
if (node.type === 'CallExpression' && node.callee?.name === 'require')
throw new Error('Runtime modules must use ES imports');
@@ -76,11 +79,14 @@ export function analyzeModule(source, { dynamic = false } = {}) {
else if (value && typeof value === 'object') walk(value, node, key);
}
}
- walk(parsers.babel.parse(source));
+ for (const script of filename.endsWith('.vue')
+ ? vueScripts(source, filename)
+ : [source])
+ walk(parsers.babel.parse(script));
return {
imports,
browser: [...browser],
- ...(dynamic && { dynamicImports }),
+ ...(dynamic && { dynamicImports, staticImports }),
};
}
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 40f59a2ea..f5127df16 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -2259,6 +2259,7 @@
"src/layers/shared/selectionKeeper.js",
"src/layers/shared/color.js",
"src/layers/submarineCables/render/display.js",
+ "src/layers/shared/render/surfacePoints.js",
"src/overlays/bindOverlayHost.js"
],
"external": ["cesium"]
@@ -3595,6 +3596,7 @@
"src/layers/earthquakes/render/display.js",
"src/layers/airQuality/render/display.js",
"src/layers/submarineCables/render/display.js",
+ "src/layers/shared/render/surfacePoints.js",
"src/layers/firms/render/sprites.js",
"src/layers/cyclones/render/display.js",
"src/layers/droneAirspace/render/display.js",
@@ -4352,6 +4354,7 @@
"src/layers/gnss/render/display.js",
"src/layers/firms/render/sprites.js",
"src/layers/submarineCables/render/display.js",
+ "src/layers/shared/render/surfacePoints.js",
"src/layers/perimeters/render/display.js",
"src/layers/droneAirspace/render/display.js",
"src/globe/ground.js",
@@ -4539,6 +4542,7 @@
"ui-share-restoration": {
"exports": ["./ui/share-restoration"],
"modules": [
+ "src/ui/layerNames.js",
"src/i18n/marker.js",
"src/data/installationFeedback.js",
"src/data/idSwitches.js",
diff --git a/scripts/qa-cables-overlay.mjs b/scripts/qa-cables-overlay.mjs
index 6627e0ed5..e5f0cf834 100644
--- a/scripts/qa-cables-overlay.mjs
+++ b/scripts/qa-cables-overlay.mjs
@@ -111,7 +111,7 @@ try {
// paths in one engine.ground corridor set, landing points and reference
// tips in mover sets, stems in engine.lines, labels on the world-overlay
// host. It owns no Cesium DataSource or Entity any more.
- const counts = await page.evaluate((layerId) => {
+ const counts = await page.evaluate((layerId, legacy) => {
const gev = window.__godsEyeView;
const v = gev.viewer;
const engine = gev.engine;
@@ -126,6 +126,7 @@ try {
out.layerDataSources++;
out.layerEntities += ds.entities.values.length;
}
+ if (legacy) return out;
for (const set of engine.ground.list()) {
if (set.id !== `${layerId}-paths`) continue;
out.pathSets++;
@@ -146,7 +147,7 @@ try {
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
out.referenceLabelCount = stats.referenceLabelCount ?? null;
return out;
- }, LAYER_ID);
+ }, LAYER_ID, legacy);
report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts);
// ── a. per-frame scene.render cost over a ~10 s driven orbit ─────────
diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs
index aae57d47f..712d8c865 100644
--- a/scripts/qa-cctv-v2.mjs
+++ b/scripts/qa-cctv-v2.mjs
@@ -1485,8 +1485,9 @@ async function main() {
const scene = viewer.scene;
const canvas = scene.canvas;
const rect = canvas.getBoundingClientRect();
- const { hitTestWorldOverlay } =
- (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine);
+ const { hitTest } = (
+ await import('/src/overlays/worldOverlay.js')
+ ).worldOverlayFor(window.__godsEyeView.engine);
const candidates = [
[0.55, 0.72],
[0.45, 0.72],
@@ -1508,7 +1509,7 @@ async function main() {
const x = Math.round(canvas.clientWidth * fx);
const y = Math.round(canvas.clientHeight * fy);
const picked = scene.pick({ x, y });
- const card = hitTestWorldOverlay(x, y, { sourceId: 'cctv' });
+ const card = hitTest(x, y, { sourceId: 'cctv' });
const top = document.elementFromPoint(rect.left + x, rect.top + y);
if (
resolveId(picked) === null &&
diff --git a/scripts/qa-directions.mjs b/scripts/qa-directions.mjs
index de8f092d9..1bc9886c3 100644
--- a/scripts/qa-directions.mjs
+++ b/scripts/qa-directions.mjs
@@ -36,8 +36,7 @@ const check = (name, passed, detail = '') => {
const browser = await puppeteer.launch({
headless: process.env.QA_HEADFUL === '1' ? false : true,
- executablePath:
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
diff --git a/scripts/qa-engine-switch.mjs b/scripts/qa-engine-switch.mjs
index 2e60003fe..1f1451859 100644
--- a/scripts/qa-engine-switch.mjs
+++ b/scripts/qa-engine-switch.mjs
@@ -65,7 +65,9 @@ await strict.run({}, async (browser) => {
(kind) =>
window.__godsEyeView?.engine?.kind === kind &&
document.querySelector('#globe-engine')?.value === kind &&
- !document.querySelector('#globe-engine').disabled &&
+ !document.querySelector(
+ '#globe-engine:is([disabled], [aria-busy="true"])',
+ ) &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90000 },
kind,
diff --git a/scripts/qa-fire-camera-switch.mjs b/scripts/qa-fire-camera-switch.mjs
index ba8f9379d..8bc4f2ebe 100644
--- a/scripts/qa-fire-camera-switch.mjs
+++ b/scripts/qa-fire-camera-switch.mjs
@@ -2,7 +2,12 @@ import { assertKeylessPreview } from './qa-strict-harness.mjs';
import assert from 'node:assert/strict';
import { installAlprFixture, cameras } from './fixtures/layers/alpr.mjs';
import { firmsFixture, FIRMS_VALID_COUNT } from './fixtures/layers/firms.mjs';
-import { createStrict, localPreview } from './qa-strict-harness.mjs';
+import {
+ clickEmptyGround,
+ createStrict,
+ localPreview,
+ waitForCameraAtRest,
+} from './qa-strict-harness.mjs';
const url = localPreview(process.argv[2]),
fire = process.argv.includes('--fires'),
id = fire ? 'local-firms' : 'alpr-cameras';
@@ -113,36 +118,14 @@ await strict.run({}, async (b) => {
);
// A click on empty ground (nothing picked there, nothing covering it).
const clickEmpty = async () => {
- const handle = await p.waitForFunction(
- () => {
- const { engine } = window.__godsEyeView;
- const canvas = engine.host.canvas;
- const box = canvas.getBoundingClientRect();
- for (let y = 240; y < 600; y += 9)
- for (let x = 440; x < 840; x += 9) {
- if (
- !canvas.contains(
- document.elementFromPoint(box.left + x, box.top + y),
- )
- )
- continue;
- let free = true;
- for (const [dx, dy] of [
- [0, 0],
- [6, 0],
- [-6, 0],
- [0, 6],
- [0, -6],
- ])
- if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false;
- if (free) return { x: box.left + x, y: box.top + y };
- }
- return false;
- },
- { timeout: 15000 },
- );
- const at = await handle.jsonValue();
- await p.mouse.click(at.x, at.y);
+ await clickEmptyGround(p, {
+ left: 440,
+ top: 240,
+ right: 840,
+ bottom: 600,
+ step: 9,
+ radius: 6,
+ });
await p.waitForFunction(
(fire) =>
fire
@@ -165,6 +148,10 @@ await strict.run({}, async (b) => {
xy,
);
await new Promise((r) => setTimeout(r, 1600));
+ // The selection flies the camera to the record (1.9 s, longer on a busy
+ // machine); a switch before it lands opens the other engine on a view
+ // between the two, where the record can be off screen.
+ await waitForCameraAtRest(p);
const expectedId = fire
? await p.evaluate(() => window.qaGround.getSelectedInfo().id)
: xy.id;
diff --git a/scripts/qa-gbfs-names.mjs b/scripts/qa-gbfs-names.mjs
index 40aa9425b..610fe1107 100644
--- a/scripts/qa-gbfs-names.mjs
+++ b/scripts/qa-gbfs-names.mjs
@@ -9,6 +9,7 @@ import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import sharp from 'sharp';
+import { fromDegrees } from '../src/geo/cartesian3.js';
import { watchBrowserPage, redact } from './qa-browserEvidence.mjs';
const urlIndex = process.argv.indexOf('--url');
@@ -150,23 +151,25 @@ async function open(mode = 'normal') {
'0'),
{ timeout: 60000 },
);
- await page.evaluate(async () => {
- const { dataManager, engine } = window.__godsEyeView;
- await window.__godsEyeView.styleManager.initialRestorePromise;
- window.__godsEyeView.styleManager.orbitController.stop();
- for (const [id, layer] of dataManager.layers) {
- if (layer.enabled)
- await dataManager.setEnabled(id, false, { origin: 'user' });
- }
- const geo = await import('/src/geo/index.js');
- engine.camera.cancelFlight();
- engine.camera.setView({
- destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
- orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
- });
- await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
- window.__godsEyeView.requestRender('gbfs-name-qa');
- });
+ await page.evaluate(
+ async (destination) => {
+ const { dataManager, engine } = window.__godsEyeView;
+ await window.__godsEyeView.styleManager.initialRestorePromise;
+ window.__godsEyeView.styleManager.orbitController.stop();
+ for (const [id, layer] of dataManager.layers) {
+ if (layer.enabled)
+ await dataManager.setEnabled(id, false, { origin: 'user' });
+ }
+ engine.camera.cancelFlight();
+ engine.camera.setView({
+ destination,
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
+ window.__godsEyeView.requestRender('gbfs-name-qa');
+ },
+ fromDegrees(-97.7449, 30.2701, 3000),
+ );
return { page, errors };
}
@@ -220,15 +223,17 @@ async function w2Checks(page) {
['close', 3000],
['global', 100000],
]) {
- await page.evaluate(async (height) => {
- const app = window.__godsEyeView;
- await app.dataManager.setEnabled('bikeshare', false);
- const geo = await import('/src/geo/index.js');
- app.engine.camera.setView({
- destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, height),
- orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
- });
- }, height);
+ await page.evaluate(
+ async (destination) => {
+ const app = window.__godsEyeView;
+ await app.dataManager.setEnabled('bikeshare', false);
+ app.engine.camera.setView({
+ destination,
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ },
+ fromDegrees(-97.7449, 30.2701, height),
+ );
await settledTiles(page);
await nextFrame(page);
const off = await page.screenshot({
@@ -304,15 +309,17 @@ async function w2Checks(page) {
for (const element of document.querySelectorAll('.gbfs-raster-hidden'))
element.classList.remove('gbfs-raster-hidden');
});
- await page.evaluate(async () => {
- const app = window.__godsEyeView;
- const geo = await import('/src/geo/index.js');
- app.engine.camera.setView({
- destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
- orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
- });
- await app.dataManager.setEnabled('bikeshare', false);
- });
+ await page.evaluate(
+ async (destination) => {
+ const app = window.__godsEyeView;
+ app.engine.camera.setView({
+ destination,
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ await app.dataManager.setEnabled('bikeshare', false);
+ },
+ fromDegrees(-97.7449, 30.2701, 3000),
+ );
for (let i = 0; i < 10; i++) {
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
@@ -520,22 +527,24 @@ try {
);
}
await page.setViewport({ width: 390, height: 844 });
- await page.evaluate(async () => {
- window.__qaPainted = [];
- const app = window.__godsEyeView;
- // Use the supported clean view to inspect the mobile card clear of panels.
- app.styleManager.toggleCleanView(true);
- const geo = await import('/src/geo/index.js');
- app.engine.camera.cancelFlight();
- app.engine.camera.setView({
- destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
- orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
- });
- app.dataManager.layers
- .get('bikeshare')
- .module._selectBikeshareStationForTest('austin-capmetro:english');
- app.requestRender('gbfs-name-mobile-qa');
- });
+ await page.evaluate(
+ async (destination) => {
+ window.__qaPainted = [];
+ const app = window.__godsEyeView;
+ // Use the supported clean view to inspect the mobile card clear of panels.
+ app.styleManager.toggleCleanView(true);
+ app.engine.camera.cancelFlight();
+ app.engine.camera.setView({
+ destination,
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ app.dataManager.layers
+ .get('bikeshare')
+ .module._selectBikeshareStationForTest('austin-capmetro:english');
+ app.requestRender('gbfs-name-mobile-qa');
+ },
+ fromDegrees(-97.7449, 30.2701, 3000),
+ );
await page.waitForFunction(
() => window.__qaPainted.includes('QA English Square'),
{ timeout: 10000 },
diff --git a/scripts/qa-ground-layer-switch.mjs b/scripts/qa-ground-layer-switch.mjs
index af368358f..1dbeeac97 100644
--- a/scripts/qa-ground-layer-switch.mjs
+++ b/scripts/qa-ground-layer-switch.mjs
@@ -2,9 +2,11 @@ import { fromDegrees } from '../src/geo/cartesian3.js';
import sharp from 'sharp';
import assert from 'node:assert/strict';
import {
+ clickEmptyGround,
createStrict,
localPreview,
openPage,
+ waitForCameraAtRest,
waitForEngine,
} from './qa-strict-harness.mjs';
@@ -400,39 +402,19 @@ await strict.run({}, async (b) => {
// Click-picking on the running engine: clear the selection with a click on
// empty water, then select the vessel with a click on its chevron.
const pickVessel = async () => {
+ // The last selection flies the camera to the vessel, and a click does
+ // not cut that flight short: click once it has landed.
+ await waitForCameraAtRest(p);
// Empty ground: a point in the middle of the view where the engine
// picks nothing and nothing covers the canvas.
- const empty = await p
- .waitForFunction(
- () => {
- const { engine } = window.__godsEyeView;
- const canvas = engine.host.canvas;
- const box = canvas.getBoundingClientRect();
- for (let y = 220; y < 560; y += 11)
- for (let x = 300; x < 720; x += 11) {
- if (
- !canvas.contains(
- document.elementFromPoint(box.left + x, box.top + y),
- )
- )
- continue;
- let free = true;
- for (const [dx, dy] of [
- [0, 0],
- [10, 0],
- [-10, 0],
- [0, 10],
- [0, -10],
- ])
- if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false;
- if (free) return { x: box.left + x, y: box.top + y };
- }
- return false;
- },
- { timeout: 15000 },
- )
- .then((handle) => handle.jsonValue());
- await p.mouse.click(empty.x, empty.y);
+ await clickEmptyGround(p, {
+ left: 300,
+ top: 220,
+ right: 720,
+ bottom: 560,
+ step: 11,
+ radius: 10,
+ });
await p.waitForFunction(
() => !window.qaVesselEntry.module.getSelectedInfo(),
);
@@ -447,6 +429,8 @@ await strict.run({}, async (b) => {
'ais-live-vessels',
'a click on the chevron selects the vessel',
);
+ // The next switch starts from the landed view.
+ await waitForCameraAtRest(p);
};
await click(-79.38, 43.65);
await p.waitForFunction(
diff --git a/scripts/qa-installation-polish.mjs b/scripts/qa-installation-polish.mjs
index d626ce8a8..0fbf308bf 100644
--- a/scripts/qa-installation-polish.mjs
+++ b/scripts/qa-installation-polish.mjs
@@ -10,8 +10,9 @@ export async function checkInstallationPan(page, dir) {
);
await sleep(3000);
await page.evaluate(async () => {
- const { getOverlayPaintRect } =
- (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine);
+ const { getPaintRect } = (
+ await import('/src/overlays/worldOverlay.js')
+ ).worldOverlayFor(window.__godsEyeView.engine);
const g = window.__godsEyeView;
const installations = g.dataManager.layers.get(
'military-installations',
@@ -28,10 +29,10 @@ export async function checkInstallationPan(page, dir) {
const sites = [],
centers = [];
installations.visitNamedMarkers((id) => {
- if (getOverlayPaintRect('military-installations', id)) sites.push(id);
+ if (getPaintRect('military-installations', id)) sites.push(id);
});
for (const id of centerIds)
- if (getOverlayPaintRect('local-datacenters', id)) centers.push(id);
+ if (getPaintRect('local-datacenters', id)) centers.push(id);
window.__polishPanFrames.push({ t: performance.now(), sites, centers });
});
});
@@ -187,8 +188,9 @@ export async function checkInstallationSelection(
window.__polishSelectionFrames = [];
const { getSelectedEntityContext } =
await import('/src/data/contextStore.js');
- const { getOverlayPaintRect, getWorldOverlayDiagnostics } =
- (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine);
+ const { getPaintRect, getDiagnostics } = (
+ await import('/src/overlays/worldOverlay.js')
+ ).worldOverlayFor(window.__godsEyeView.engine);
// The selected site's fill is one engine.ground polygon set
// (render/display.js SELECTION_FILL_SET_ID), an item per footprint
// part; the layer owns no Cesium entities or ground primitives.
@@ -225,17 +227,13 @@ export async function checkInstallationSelection(
g.viewer.scene.postRender.addEventListener(() => {
window.__polishSelectionFrames.push({
paints: window.__polishTitlePaints.splice(0).length,
- own: Boolean(
- getOverlayPaintRect('military-installations', record.id),
- ),
+ own: Boolean(getPaintRect('military-installations', record.id)),
tracked: Boolean(
- getOverlayPaintRect('tracked', `installations:${record.id}`),
+ getPaintRect('tracked', `installations:${record.id}`),
),
selected: getSelectedEntityContext()?.id === record.id,
total:
- getWorldOverlayDiagnostics().paintedBySource[
- 'military-installations'
- ] || 0,
+ getDiagnostics().paintedBySource['military-installations'] || 0,
});
});
return {
diff --git a/scripts/qa-label-readability.mjs b/scripts/qa-label-readability.mjs
index 0d22a7393..23a8c0807 100644
--- a/scripts/qa-label-readability.mjs
+++ b/scripts/qa-label-readability.mjs
@@ -7,8 +7,8 @@
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after [--engine=cesium|navara]
*
* Engine-neutral: camera, tiles and layers go through the globe engine and the
- * QA probe; an engine without the flights/satellites layers (Navara today)
- * shoots the scenes without a contact field and says so.
+ * QA probe; an engine without the flights/satellites layers shoots the
+ * scenes without a contact field and says so.
*
* Every scene pins its own camera pose AND substitutes a deterministic
* synthetic contact field for the live pollers, so a `before` run and an
diff --git a/scripts/qa-labels.mjs b/scripts/qa-labels.mjs
index 84a51b470..332d16a80 100644
--- a/scripts/qa-labels.mjs
+++ b/scripts/qa-labels.mjs
@@ -7,8 +7,8 @@
*
* Engine-neutral: the camera and frames go through the globe engine and the
* layers through the QA probe (window.__godsEyeView.layers). On an engine
- * without the flights, military and satellites layers (Navara today) the
- * field checks are SKIPPED and the settings checks still run.
+ * without the flights, military and satellites layers the field checks are
+ * SKIPPED and the settings checks still run.
*
* The harness replaces only the three detection read APIs in the loaded page.
* It does not enable their network pollers or mutate repository data. The field
diff --git a/scripts/qa-layer-handoff.mjs b/scripts/qa-layer-handoff.mjs
index 5f0e6aa76..77e9480a2 100644
--- a/scripts/qa-layer-handoff.mjs
+++ b/scripts/qa-layer-handoff.mjs
@@ -111,7 +111,9 @@ await strict.run({}, async (browser) => {
page.waitForFunction(
(engine) =>
window.__godsEyeView?.engine?.kind === engine &&
- !document.querySelector('#globe-engine')?.disabled &&
+ !document.querySelector(
+ '#globe-engine:is([disabled], [aria-busy="true"])',
+ ) &&
document.querySelector('#loading-screen.hidden') &&
!document.querySelector('.engine-switch-frame'),
{ timeout: 90000 },
diff --git a/scripts/qa-military-names.mjs b/scripts/qa-military-names.mjs
index 04584ca7d..fa84bbdf7 100644
--- a/scripts/qa-military-names.mjs
+++ b/scripts/qa-military-names.mjs
@@ -152,14 +152,15 @@ try {
await settle();
const before = await stats();
const overlaps = await page.evaluate(async () => {
- const { getOverlayPaintRect } =
- (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine);
+ const { getPaintRect } = (
+ await import('/src/overlays/worldOverlay.js')
+ ).worldOverlayFor(window.__godsEyeView.engine);
const layer = window.__godsEyeView.dataManager.layers.get(
'military-installations',
).module;
const rects = [];
layer.visitNamedMarkers((id) => {
- const rect = getOverlayPaintRect('military-installations', id);
+ const rect = getPaintRect('military-installations', id);
if (rect) rects.push(rect);
});
let overlaps = 0;
@@ -223,9 +224,7 @@ try {
opacityProperty: true,
}),
labels:
- host.getWorldOverlayDiagnostics().paintedBySource[
- 'military-installations'
- ] || 0,
+ host.getDiagnostics().paintedBySource['military-installations'] || 0,
};
});
assert.ok(
@@ -244,10 +243,10 @@ try {
),
);
result.disabled = await page.evaluate(async () => {
- const host = (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine);
- return host.getWorldOverlayDiagnostics().entriesBySource[
- 'military-installations'
- ];
+ const host = (
+ await import('/src/overlays/worldOverlay.js')
+ ).worldOverlayFor(window.__godsEyeView.engine);
+ return host.getDiagnostics().entriesBySource['military-installations'];
});
assert.equal(result.disabled, 0);
await page.evaluate(() =>
@@ -266,8 +265,9 @@ try {
}
await page.evaluate(async () => {
const C = await import('/node_modules/cesium/Build/Cesium/index.js');
- const { getOverlayPaintRect } =
- (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine);
+ const { getPaintRect } = (
+ await import('/src/overlays/worldOverlay.js')
+ ).worldOverlayFor(window.__godsEyeView.engine);
const g = window.__godsEyeView;
const layer = g.dataManager.layers.get('military-installations').module;
const overlay = document.createElement('div');
@@ -284,7 +284,7 @@ try {
labels = [],
rects = [];
layer.visitNamedMarkers((id, point) => {
- const rect = getOverlayPaintRect('military-installations', id);
+ const rect = getPaintRect('military-installations', id);
if (rect) rects.push(rect);
const p = C.SceneTransforms.worldToWindowCoordinates(
g.viewer.scene,
diff --git a/scripts/qa-navara-draw.mjs b/scripts/qa-navara-draw.mjs
index 8da65b128..84615d832 100644
--- a/scripts/qa-navara-draw.mjs
+++ b/scripts/qa-navara-draw.mjs
@@ -48,7 +48,9 @@ await strict.run(
(k) =>
window.__godsEyeView?.engine?.kind === k &&
window.__gevDrawTool?.attached &&
- !document.querySelector('#globe-engine')?.disabled &&
+ !document.querySelector(
+ '#globe-engine:is([disabled], [aria-busy="true"])',
+ ) &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout: 120000 },
diff --git a/scripts/qa-navara-geo-import.mjs b/scripts/qa-navara-geo-import.mjs
index 84a919d30..728a09b42 100644
--- a/scripts/qa-navara-geo-import.mjs
+++ b/scripts/qa-navara-geo-import.mjs
@@ -2,8 +2,8 @@
// UTILITIES ▸ Import on Navara under the strict network policy: GeoJSON,
// KML, KMZ (a packed icon and a ground overlay) and GPX through the real
// file input, each one row; Navara draws their shapes in the row colour and
-// their pins, counts what it cannot draw (the KMZ ground overlay) and says
-// so in the hint; hide, fly-to and remove work on the engine; the files
+// their pins and packed ground overlay; unsupported content is counted
+// in the hint; hide, fly-to and remove work on the engine; the files
// survive a switch to Cesium and back and draw on each engine. No file makes
// the page request anything it names (the strict policy fails any request
// outside the preview and the free tile cache).
@@ -152,7 +152,9 @@ await strict.run(
(k) =>
window.__godsEyeView?.engine?.kind === k &&
window.__gevGeoImport?.diagnostics().attached &&
- !document.querySelector('#globe-engine')?.disabled &&
+ !document.querySelector(
+ '#globe-engine:is([disabled], [aria-busy="true"])',
+ ) &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout: 120000 },
@@ -171,7 +173,7 @@ await strict.run(
rows: document.querySelectorAll('#geo-import-list .geo-import-row')
.length,
hint: document.getElementById('geo-import-hint')?.textContent || '',
- // Navara draws the pins as mover sets; Cesium as entities (none).
+ // Both engines draw imported pins through mover sets.
pins: window.__godsEyeView.engine.movers
.list()
.filter(({ id }) => id.startsWith('geo-import-'))
diff --git a/scripts/qa-overlay-baseline.mjs b/scripts/qa-overlay-baseline.mjs
index 6a5fbf47f..719bfb09c 100644
--- a/scripts/qa-overlay-baseline.mjs
+++ b/scripts/qa-overlay-baseline.mjs
@@ -18,7 +18,7 @@
* node scripts/qa-overlay-baseline.mjs --engine=navara
*
* Engine-neutral: the camera and frames go through the globe engine and the
- * layers through the QA probe (window.__godsEyeView.layers, dev server only).
+ * layers through the public engine and data manager surfaces.
* A scene whose layers the engine does not have is SKIPPED. The inventory
* counts the globe contract's sets (movers, lines, ground, imagery) on both
* engines; on Cesium it also counts what is left of Entities, LabelGraphics
@@ -29,6 +29,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
+import { fromDegrees } from '../src/geo/cartesian3.js';
const CHROME_EXECUTABLE = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const DEFAULT_URL = 'http://localhost:4176';
@@ -362,7 +363,7 @@ async function installPageInstrumentation(page) {
async function waitForApp(page) {
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(
- () => Boolean(window.__godsEyeView?.layers && window.__godsEyeView?.dataManager),
+ () => Boolean(window.__godsEyeView?.engine && window.__godsEyeView?.dataManager),
{ timeout: 60_000 },
);
await page.evaluate(() => {
@@ -375,17 +376,16 @@ async function waitForApp(page) {
async function setCamera(page, camera) {
if (!camera) return;
- await page.evaluate(async ([lon, lat, height, heading, pitch]) => {
+ await page.evaluate(([destination, heading, pitch]) => {
const { engine, viewer } = window.__godsEyeView;
- const geo = await import('/src/geo/index.js');
engine.camera.cancelFlight();
viewer?.scene.tweens?.removeAll?.();
engine.camera.setView({
- destination: geo.cartesian3.fromDegrees(lon, lat, height),
+ destination,
orientation: { heading, pitch, roll: 0 },
});
engine.render.request('qa-overlay-baseline');
- }, camera);
+ }, [fromDegrees(...camera.slice(0, 3)), ...camera.slice(3)]);
}
async function readLayerState(page, layerId) {
@@ -646,7 +646,7 @@ async function prepareTrackedFlight(page, cockpit) {
});
if (!tracked.ok) return `Civil tracking unavailable (${tracked.reason})`;
try {
- await page.waitForFunction(() => Boolean(window.__godsEyeView.layers.following()), { timeout: 10_000 });
+ await page.waitForFunction(() => Boolean(window.__godsEyeView.engine.camera.following()), { timeout: 10_000 });
} catch {
return 'Civil tracking did not create a tracked entity';
}
@@ -679,7 +679,7 @@ async function prepareMission(page) {
});
if (!selected) return 'Mission roster did not expose a selectable mission';
try {
- await page.waitForFunction(() => Boolean(window.__godsEyeView.layers.drawn('rocket-launches')?.selected), { timeout: 10_000 });
+ await page.waitForFunction(() => Boolean(window.__godsEyeView.dataManager.layers.get('rocket-launches')?.module?.qaDrawn?.()?.selected), { timeout: 10_000 });
} catch {
return 'Mission selection did not reach the layer probe';
}
@@ -689,7 +689,7 @@ async function prepareMission(page) {
async function prepareScene(page, scene) {
const missing = await page.evaluate(
- (ids) => ids.filter((id) => !window.__godsEyeView.layers.available(id)),
+ (ids) => ids.filter((id) => !window.__godsEyeView.dataManager.layers.has(id)),
scene.layers,
);
if (missing.length)
diff --git a/scripts/qa-perf.mjs b/scripts/qa-perf.mjs
index d41413a99..0a7730943 100644
--- a/scripts/qa-perf.mjs
+++ b/scripts/qa-perf.mjs
@@ -71,7 +71,7 @@
* Usage: node scripts/qa-perf.mjs [--url http://localhost:4173] [--engine=cesium|navara]
* Engine-neutral: the camera, frames and layers go through the globe engine
* and the QA probe (window.__godsEyeView.layers); on an engine without a
- * layer (Navara today) that layer's checks are SKIPPED, not failed.
+ * layer that layer's checks are SKIPPED, not failed.
* Requires a running dev server. Headless; flags disable occlusion
* throttling so rAF cadence is trustworthy (hidden-pane gotcha).
*/
@@ -338,14 +338,16 @@ try {
await page.evaluate(() => { window.__godsEyeView.styleManager.setStyle('retro'); });
await new Promise((r) => setTimeout(r, 900)); // crossfade + first ticks
const dAnim = await diag();
- // The animated styles are Cesium post-process stages; Navara draws its own
- // looks and has no style animation (a known gap, not a regression).
+ // This assertion checks the Cesium style-anim render hold.
if (engineKind === 'cesium') {
check('animated style takes the style-anim hold (continuous)', dAnim?.mode === 'continuous' && dAnim.holds.includes('style-anim'), dAnim);
const animFrames = await countFrames(2_000);
check('animated style keeps frames flowing (≥50% rAF)', animFrames.renders >= animFrames.rafs * 0.5, animFrames);
} else {
- skip('animated style takes the style-anim hold and keeps frames flowing', `the visual styles are Cesium post stages; ${engineKind} has none`);
+ skip(
+ 'animated style takes the style-anim hold and keeps frames flowing',
+ `this check measures the Cesium style-anim hold, not ${engineKind} animation`,
+ );
}
// Returning to normal keeps detection ON by design (it is the default, and
// detection persists across style switches). Since 2026-08-22 detection holds
diff --git a/scripts/qa-strict-context-loss.mjs b/scripts/qa-strict-context-loss.mjs
index 64a9c9dff..60c530c6d 100644
--- a/scripts/qa-strict-context-loss.mjs
+++ b/scripts/qa-strict-context-loss.mjs
@@ -67,7 +67,9 @@ await strict.run({}, async (b) => {
await p.waitForFunction(
() =>
(window.__godsEyeView &&
- !document.querySelector('#globe-engine')?.disabled &&
+ !document.querySelector(
+ '#globe-engine:is([disabled], [aria-busy="true"])',
+ ) &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden')) ||
document.querySelector('.engine-switch-retry'),
diff --git a/scripts/qa-strict-failure-matrix.mjs b/scripts/qa-strict-failure-matrix.mjs
index e70ed6fa3..e97ed491d 100644
--- a/scripts/qa-strict-failure-matrix.mjs
+++ b/scripts/qa-strict-failure-matrix.mjs
@@ -229,7 +229,9 @@ await strict.run({}, async (b) => {
window.__qaSlow.at &&
!window.__qaSlow.coverGone &&
!document.querySelector('.engine-switch-frame') &&
- !document.querySelector('#globe-engine')?.disabled
+ !document.querySelector(
+ '#globe-engine:is([disabled], [aria-busy="true"])',
+ )
) {
window.__qaSlow.coverGone = performance.now();
observer.disconnect();
@@ -245,7 +247,9 @@ await strict.run({}, async (b) => {
await p.waitForFunction(
(target) =>
window.__godsEyeView?.engine?.kind === target &&
- !document.querySelector('#globe-engine')?.disabled &&
+ !document.querySelector(
+ '#globe-engine:is([disabled], [aria-busy="true"])',
+ ) &&
!document.querySelector('.engine-switch-frame'),
{ polling: 200, timeout: 60000 },
target,
diff --git a/scripts/qa-strict-harness.mjs b/scripts/qa-strict-harness.mjs
index f88692f88..3a6d6100c 100644
--- a/scripts/qa-strict-harness.mjs
+++ b/scripts/qa-strict-harness.mjs
@@ -440,10 +440,11 @@ export async function pickEngine(
?.getAttribute('aria-expanded') === 'true',
);
}
- await page.waitForSelector('#globe-engine:not([disabled])', {
- visible: true,
- timeout: 90000,
- });
+ // The picker stays focusable while busy: aria-busy marks a switch.
+ await page.waitForSelector(
+ '#globe-engine:not([disabled]):not([aria-busy="true"])',
+ { visible: true, timeout: 90000 },
+ );
const from = await page.$eval('#globe-engine', (el) => el.value);
assert.notEqual(from, kind, `the picker already shows ${kind}`);
// The key typed below is `kind`'s first letter: it selects the next option
@@ -479,14 +480,16 @@ export async function pickEngine(
);
window.__qaPickObserver?.disconnect();
window.__qaPickObserver = new MutationObserver(() => {
- const disabled = document.querySelector('#globe-engine')?.disabled;
+ const disabled = document.querySelector(
+ '#globe-engine:is([disabled], [aria-busy="true"])',
+ );
if (disabled) window.__qaPick.busy = true;
else if (window.__qaPick.busy && window.__qaPick.idleAt == null)
window.__qaPick.idleAt = performance.now();
});
window.__qaPickObserver.observe(document.body, {
attributes: true,
- attributeFilter: ['disabled'],
+ attributeFilter: ['disabled', 'aria-busy'],
subtree: true,
});
const describe = (hit) =>
@@ -523,8 +526,10 @@ export async function pickEngine(
// Whatever the person does next, right after the key (no round trip to
// the page in between).
if (afterInput) await afterInput();
+ // The change starts a cold switch at once: Cesium.js then runs as one
+ // task, seconds long under CPU throttling, before the page answers.
await page.waitForFunction(() => window.__qaPick?.changed, {
- timeout: 5000,
+ timeout: 30000,
polling: 50,
});
// Headless Chrome opens no menu for a click on the select: the key
@@ -538,7 +543,9 @@ export async function pickEngine(
await page.waitForFunction(
() =>
(window.__qaPick.busy &&
- !document.querySelector('#globe-engine')?.disabled &&
+ !document.querySelector(
+ '#globe-engine:is([disabled], [aria-busy="true"])',
+ ) &&
document.querySelector('#loading-screen.hidden') &&
!document.querySelector('.engine-switch-frame')) ||
document.querySelector('.engine-switch-retry'),
@@ -572,7 +579,9 @@ export async function waitForEngine(page, kind, { timeout = 120000 } = {}) {
window.__godsEyeView?.engine?.kind ===
(expected === 'cesium' ? 'cesium' : 'navara') &&
document.querySelector('#globe-engine')?.value === expected &&
- !document.querySelector('#globe-engine')?.disabled &&
+ !document.querySelector(
+ '#globe-engine:is([disabled], [aria-busy="true"])',
+ ) &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout },
@@ -837,3 +846,65 @@ export function createStrict({
};
return strict;
}
+
+/** Click uncovered canvas where the engine picks nothing within the radius. */
+export async function clickEmptyGround(
+ page,
+ { left, top, right, bottom, step = 9, radius = 6 },
+) {
+ const handle = await page.waitForFunction(
+ ({ left, top, right, bottom, step, radius }) => {
+ const { engine } = window.__godsEyeView;
+ const canvas = engine.host.canvas;
+ const box = canvas.getBoundingClientRect();
+ for (let y = top; y < bottom; y += step)
+ for (let x = left; x < right; x += step) {
+ if (
+ !canvas.contains(
+ document.elementFromPoint(box.left + x, box.top + y),
+ )
+ )
+ continue;
+ const free = [
+ [0, 0],
+ [radius, 0],
+ [-radius, 0],
+ [0, radius],
+ [0, -radius],
+ ].every(([dx, dy]) => !engine.picker.pick({ x: x + dx, y: y + dy }));
+ if (free) return { x: box.left + x, y: box.top + y };
+ }
+ return false;
+ },
+ { timeout: 15000 },
+ { left, top, right, bottom, step, radius },
+ );
+ try {
+ const at = await handle.jsonValue();
+ await page.mouse.click(at.x, at.y);
+ } finally {
+ await handle.dispose();
+ }
+}
+
+/**
+ * Wait until no flight holds the camera and it stays still. A selection's
+ * camera transfer is a flight; on both engines a flight turns the camera
+ * inputs off, and a click does not cut it short.
+ */
+export async function waitForCameraAtRest(page, { timeout = 15000 } = {}) {
+ await page.evaluate(() => {
+ window.__qaRestPose = null;
+ });
+ await page.waitForFunction(
+ () => {
+ const { engine } = window.__godsEyeView;
+ const { x, y, z } = engine.camera.pose().position;
+ const pose = [x, y, z].map((value) => value.toFixed(2)).join();
+ const still = window.__qaRestPose === pose;
+ window.__qaRestPose = pose;
+ return still && engine.input.cameraInputsEnabled();
+ },
+ { timeout, polling: 100 },
+ );
+}
diff --git a/scripts/qa-strict-hidden-switch.mjs b/scripts/qa-strict-hidden-switch.mjs
index deef7ca24..1d2b9ce64 100644
--- a/scripts/qa-strict-hidden-switch.mjs
+++ b/scripts/qa-strict-hidden-switch.mjs
@@ -52,7 +52,11 @@ await strict.run({}, async (b) => {
window.__qaHidden.shown.push({
state: document.visibilityState,
at: performance.now(),
- busy: !!document.querySelector('#globe-engine')?.disabled,
+ busy: Boolean(
+ document.querySelector(
+ '#globe-engine:is([disabled], [aria-busy="true"])',
+ ),
+ ),
}),
);
}, kind);
@@ -69,7 +73,9 @@ await strict.run({}, async (b) => {
await p.waitForFunction(
(kind) =>
window.__godsEyeView?.engine?.kind === kind &&
- !document.querySelector('#globe-engine')?.disabled &&
+ !document.querySelector(
+ '#globe-engine:is([disabled], [aria-busy="true"])',
+ ) &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ polling: 250, timeout: 60000 },
diff --git a/scripts/qa-strict-parked-release.mjs b/scripts/qa-strict-parked-release.mjs
index 1a3e54cec..db44ada6b 100644
--- a/scripts/qa-strict-parked-release.mjs
+++ b/scripts/qa-strict-parked-release.mjs
@@ -1,4 +1,4 @@
-// Contract: a parked globe is released after ?parkedMs= (3 s here; three
+// Contract: a parked globe is released after ?parkedMs= (8 s here; three
// minutes by default). A switch back in time is warm (the same engine
// object); after the delay the parked engine's WebGL context is lost and a
// switch back builds a new engine on the same session that draws a
@@ -21,7 +21,8 @@ import {
startSession,
} from './qa-strict-session.mjs';
-const PARKED_MS = 3000;
+// Room for a cold switch on a busy machine (2-3 s) before the switch back.
+const PARKED_MS = 8000;
const base = localPreview(process.argv[2]);
const other = (kind) => (kind === 'navara' ? 'cesium' : 'navara');
const strict = createStrict({ origin: base.origin, layers: [] });
@@ -41,8 +42,9 @@ await strict.run({}, async (b) => {
console.log('PASS', name);
};
const pick = async (kind) => {
- await strict.pick(p, kind);
+ const { at } = await strict.pick(p, kind);
await waitForEngine(p, kind);
+ return at;
};
// The running engine is the one remembered under its kind (warm), and
// the context of `kind`'s remembered engine is lost or not.
@@ -77,17 +79,17 @@ await strict.run({}, async (b) => {
await pick(parked);
await rememberEngine(p);
}
- await pick(running);
+ const parkedAt = await pick(running);
await rememberEngine(p);
// A switch back in time is warm.
- await wait(PARKED_MS / 3);
- await pick(parked);
+ await wait(1000);
+ const backAt = await pick(parked);
const early = await engines(parked);
await assertSession(p, parked, `a quick switch back to ${parked}`);
contract(
`a switch back to ${parked} within the delay reuses the parked engine`,
early.warm && early.lost === false,
- early,
+ { ...early, parkedMs: Math.round(backAt - parkedAt) },
);
// Parked past the delay: released, and the next switch back is cold.
await pick(running);
diff --git a/scripts/qa-strict-rapid-switch.mjs b/scripts/qa-strict-rapid-switch.mjs
index fedc95c7d..aa10158e9 100644
--- a/scripts/qa-strict-rapid-switch.mjs
+++ b/scripts/qa-strict-rapid-switch.mjs
@@ -89,7 +89,11 @@ await strict.run({}, async (b) => {
window.__qaSteps.push({
step,
engine: window.__godsEyeView?.engine?.kind ?? null,
- busy: !!document.querySelector('#globe-engine')?.disabled,
+ busy: Boolean(
+ document.querySelector(
+ '#globe-engine:is([disabled], [aria-busy="true"])',
+ ),
+ ),
});
window.__qaMark = mark;
});
diff --git a/scripts/qa-strict-session.test.mjs b/scripts/qa-strict-session.test.mjs
index 1522bbe53..9d6f5b2c4 100644
--- a/scripts/qa-strict-session.test.mjs
+++ b/scripts/qa-strict-session.test.mjs
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { quantile, slope } from './qa-strict-session.mjs';
-import { strictArguments } from './qa-strict-harness.mjs';
+import { strictArguments, waitForCameraAtRest } from './qa-strict-harness.mjs';
test('slope is the least-squares growth per step', () => {
assert.equal(slope([[0, 5]]), 0);
@@ -45,3 +45,32 @@ test('the runner hands each scenario its layers and its manifest path', () => {
evidence: process.env.QA_STRICT_EVIDENCE ?? null,
});
});
+
+test('the camera is at rest once a flight has landed and it stays still', async () => {
+ // A selection's flight holds the inputs off; once they are on, the camera
+ // still moves one more sample before it stays put.
+ const poses = [0, 400, 900, 900, 1250, 1250, 1250];
+ const inputs = [false, false, false, false, true, true, true];
+ let frame = 0;
+ const engine = {
+ camera: { pose: () => ({ position: { x: poses[frame], y: 0, z: 6.4e6 } }) },
+ input: { cameraInputsEnabled: () => inputs[frame] },
+ };
+ const previous = globalThis.window;
+ globalThis.window = { __godsEyeView: { engine } };
+ const page = {
+ evaluate: async (fn) => fn(),
+ async waitForFunction(fn) {
+ for (; frame < poses.length; frame++) if (fn()) return;
+ throw new Error('timed out');
+ },
+ };
+ try {
+ await waitForCameraAtRest(page);
+ // Not while the flight holds the inputs (sample 3 repeats a pose), nor
+ // while the camera moves (sample 4).
+ assert.equal(frame, 5);
+ } finally {
+ globalThis.window = previous;
+ }
+});
diff --git a/scripts/qa-traffic-motion.mjs b/scripts/qa-traffic-motion.mjs
index 0d68d411b..6973d0d99 100644
--- a/scripts/qa-traffic-motion.mjs
+++ b/scripts/qa-traffic-motion.mjs
@@ -121,12 +121,14 @@ try {
// place every frame; it never empties and refills a collection).
// --baseline records the pre-contract build, whose PointPrimitive
// collections sat in scene.primitives and were rebuilt by removeAll.
- const engine = window.__godsEyeView.engine;
- const createMoverSet = engine.movers.createSet.bind(engine.movers);
- engine.movers.createSet = (options) => {
- if (/^traffic-(fill|outline)$/.test(options?.id ?? '')) rebuilds++;
- return createMoverSet(options);
- };
+ if (!baseline) {
+ const engine = window.__godsEyeView.engine;
+ const createMoverSet = engine.movers.createSet.bind(engine.movers);
+ engine.movers.createSet = (options) => {
+ if (/^traffic-(fill|outline)$/.test(options?.id ?? '')) rebuilds++;
+ return createMoverSet(options);
+ };
+ }
function discoverCollections() {
if (!baseline) return;
for (let i = 0; i < scene.primitives.length; i++) {
diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs
index 249165497..735194ad1 100644
--- a/scripts/qa-transit-browser.mjs
+++ b/scripts/qa-transit-browser.mjs
@@ -201,7 +201,7 @@ export async function sampleTransitPixels(page, palette) {
?.getDetectionDiagnostics?.()
?.calloutRects?.filter((r) => r.alpha > 0.01) || [];
if (state._selectedKey) {
- const card = overlay?.getOverlayPaintRect(
+ const card = overlay?.getPaintRect(
'transit-selected',
state._selectedKey,
);
diff --git a/scripts/qa-transit-heading.mjs b/scripts/qa-transit-heading.mjs
index 208afad58..36530c829 100644
--- a/scripts/qa-transit-heading.mjs
+++ b/scripts/qa-transit-heading.mjs
@@ -8,12 +8,11 @@ export async function runTransitHeadingRegression(base, check) {
base = transitQaBase(base);
const browser = await puppeteer.launch({
headless: true,
- executablePath:
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
- '--use-angle=metal',
+ ...(process.platform === 'darwin' ? ['--use-angle=metal'] : []),
'--enable-gpu',
],
});
diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs
index 3622dbb36..ff23998e7 100644
--- a/scripts/qa-transit-recovery.mjs
+++ b/scripts/qa-transit-recovery.mjs
@@ -11,10 +11,9 @@ const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
await mkdir(shots, { recursive: true });
const browser = await puppeteer.launch({
headless: true,
- executablePath:
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ executablePath: chromeExecutablePath(),
args: [
- '--use-angle=metal',
+ ...(process.platform === 'darwin' ? ['--use-angle=metal'] : []),
'--enable-gpu',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs
index a9e28bac3..28aeee4b4 100644
--- a/scripts/qa-transit.mjs
+++ b/scripts/qa-transit.mjs
@@ -1657,7 +1657,7 @@ try {
const t0 = performance.now();
const tick = () => {
const entry = state._vehicles.get(key);
- const rect = overlay.getOverlayPaintRect('transit-selected', key);
+ const rect = overlay.getPaintRect('transit-selected', key);
if (entry?.marker) {
const view = scene.camera.viewMatrix;
const proj = scene.camera.frustum.projectionMatrix;
diff --git a/scripts/qa-urban-engine-switch.mjs b/scripts/qa-urban-engine-switch.mjs
index c9d749d60..7d0a5b8be 100644
--- a/scripts/qa-urban-engine-switch.mjs
+++ b/scripts/qa-urban-engine-switch.mjs
@@ -1,6 +1,12 @@
import assert from 'node:assert/strict';
import { fromDegrees } from '../src/geo/cartesian3.js';
-import { createStrict, localPreview, openPage, waitForEngine } from './qa-strict-harness.mjs';
+import {
+ clickEmptyGround,
+ createStrict,
+ localPreview,
+ openPage,
+ waitForEngine,
+} from './qa-strict-harness.mjs';
import { installTransitFixture } from './fixtures/layers/transit.mjs';
import {
combinedPayloads,
@@ -128,36 +134,14 @@ await strict.run({}, async (b) => {
);
// A click on empty ground (nothing picked around it, nothing covering it).
const clickEmpty = async () => {
- const handle = await p.waitForFunction(
- () => {
- const { engine } = window.__godsEyeView;
- const canvas = engine.host.canvas;
- const box = canvas.getBoundingClientRect();
- for (let y = 230; y < 600; y += 9)
- for (let x = 440; x < 840; x += 9) {
- if (
- !canvas.contains(
- document.elementFromPoint(box.left + x, box.top + y),
- )
- )
- continue;
- let free = true;
- for (const [dx, dy] of [
- [0, 0],
- [8, 0],
- [-8, 0],
- [0, 8],
- [0, -8],
- ])
- if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false;
- if (free) return { x: box.left + x, y: box.top + y };
- }
- return false;
- },
- { timeout: 15000 },
- );
- const at = await handle.jsonValue();
- await p.mouse.click(at.x, at.y);
+ await clickEmptyGround(p, {
+ left: 440,
+ top: 230,
+ right: 840,
+ bottom: 600,
+ step: 9,
+ radius: 8,
+ });
await p.waitForFunction(
() =>
!window.qaUrban
diff --git a/scripts/qa-wildlife-switch.mjs b/scripts/qa-wildlife-switch.mjs
index 8c1f8da5c..f423e1280 100644
--- a/scripts/qa-wildlife-switch.mjs
+++ b/scripts/qa-wildlife-switch.mjs
@@ -308,7 +308,11 @@ await strict.run({}, async (b) => {
engine: window.__godsEyeView?.engine?.kind ?? null,
picker: document.querySelector('#globe-engine')?.value ?? null,
focus: document.activeElement?.id ?? null,
- busy: document.querySelector('#globe-engine')?.disabled ?? null,
+ busy: Boolean(
+ document.querySelector(
+ '#globe-engine:is([disabled], [aria-busy="true"])',
+ ),
+ ),
frame: Boolean(document.querySelector('.engine-switch-frame')),
loading: document.querySelector('#loading-screen')?.className ?? null,
}));
@@ -343,6 +347,9 @@ await strict.run({}, async (b) => {
let selected = null;
for (const { kind, via, click: id } of LEGS) {
if (via) {
+ const poseBefore = await p.evaluate(() =>
+ window.__godsEyeView.engine.camera.pose(),
+ );
const reads = { ...fixture.asked };
const sighting = byId.get(selected);
if (via === 'keyboard') {
@@ -387,6 +394,29 @@ await strict.run({}, async (b) => {
console.log('PASS', kind, 'picker click closed the card');
selected = null;
}
+ const poseAfter = await p.evaluate(() =>
+ window.__godsEyeView.engine.camera.pose(),
+ );
+ const positionErrorM = Math.hypot(
+ ...['x', 'y', 'z'].map(
+ (axis) => poseAfter.position[axis] - poseBefore.position[axis],
+ ),
+ );
+ const angleError = (key) =>
+ Math.abs(
+ Math.atan2(
+ Math.sin(poseAfter[key] - poseBefore[key]),
+ Math.cos(poseAfter[key] - poseBefore[key]),
+ ),
+ );
+ await strict.check(
+ p,
+ LAYER,
+ 'switch preserves the camera position and orientation',
+ positionErrorM < 1 &&
+ ['heading', 'pitch', 'roll'].every((key) => angleError(key) < 1e-4),
+ { poseBefore, poseAfter, positionErrorM },
+ );
await frame();
}
diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs
index e00dadfc3..caf4f56e6 100644
--- a/scripts/track-regression.mjs
+++ b/scripts/track-regression.mjs
@@ -2014,8 +2014,13 @@ async function main() {
await mil.update(v);
return {
groundSnap, milGroundSnap, detectable, airSnap, groundAgainSnap,
- droppedGone: !snap('aaa066'),
- milDroppedGone: !snap('bbb166'),
+ // Removed contacts can leave hidden slots in an engine's sprite pool.
+ droppedGone: !window.__godsEyeView.layers
+ .drawn('flights')
+ .some((row) => row.id === 'aaa066'),
+ milDroppedGone: !window.__godsEyeView.layers
+ .drawn('military')
+ .some((row) => row.id === 'bbb166'),
};
});
const fmtSnap = (s) => (s ? `show=${s.show} scale=${s.scale.toFixed(3)} rgba=(${s.red.toFixed(2)},${s.green.toFixed(2)},${s.blue.toFixed(2)},${s.alpha.toFixed(2)})` : 'missing');
@@ -2296,12 +2301,28 @@ async function main() {
try { p = coll.get(i); } catch { continue; }
if (!p) continue;
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
- if (p.modelMatrix && typeof p.ready !== 'undefined' && p.id === id) found = p;
+ // A hidden pool slot can keep a removed contact's id: prefer
+ // the model shown.
+ if (p.modelMatrix && typeof p.ready !== 'undefined' && p.id === id && (!found || !found.show)) found = p;
}
};
walk(v.scene.primitives);
return found;
};
+ window.__g3dCountModels = function (id) {
+ let n = 0;
+ const walk = (coll) => {
+ for (let i = 0; i < coll.length; i++) {
+ let p;
+ try { p = coll.get(i); } catch { continue; }
+ if (!p) continue;
+ if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
+ if (p.modelMatrix && typeof p.ready !== 'undefined' && p.id === id) n++;
+ }
+ };
+ walk(window.__godsEyeView.viewer.scene.primitives);
+ return n;
+ };
});
// (a)+(b): both grounded planes become models under the cap (the old code
@@ -2316,8 +2337,19 @@ async function main() {
skip('ground-3d: grounded planes are model-eligible and modeled (both layers)',
`independent GLB control unavailable (${glbControlDetail}); grounded GLB readiness is not observable`);
} else {
+ // On a miss, say what each layer's own model handle holds.
+ const g3dWhy = g3dModelsUp ? null : await evalPage(() => {
+ const dm = window.__godsEyeView.dataManager;
+ const of = (layerId, id) => {
+ const { model } = dm.layers.get(layerId).module.testing._aircraftHandlesForTest(id);
+ return model
+ ? `${id}: handle ready=${model.ready} show=${model.show} failed=${model.failed}, engine model ready=${window.__g3dFindModel(id)?.ready} show=${window.__g3dFindModel(id)?.show} (${window.__g3dCountModels?.(id)} with this id)`
+ : `${id}: no model handle`;
+ };
+ return `${of('flights', 'aaa077')}; ${of('military', 'bbb177')}`;
+ });
record('ground-3d: grounded planes are model-eligible and modeled (both layers)', g3dModelsUp,
- g3dModelsUp ? 'fleet models up (ready+shown)' : 'models never became ready+shown');
+ g3dModelsUp ? 'fleet models up (ready+shown)' : `models never became ready+shown (${g3dWhy})`);
}
if (g3dModelsUp) {
@@ -2767,8 +2799,10 @@ async function main() {
});
await nextFrames(3);
const angDiff = (a, b) => Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b)));
- const probe = async (id, course) => {
- const bb = findBB(id);
+ const probe = async (layerId, id, course) => {
+ // Tamper with the layer's own glyph handle: a write to the engine's
+ // billboard bypasses the layer's sprite cache, so no pass rewrites it.
+ const bb = dm.layers.get(layerId).module.testing._aircraftHandlesForTest(id).sprite;
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
@@ -2813,8 +2847,8 @@ async function main() {
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0;
return {
- flights: await probe('aaa002', flightCourse),
- military: await probe('bbb101', militaryCourse),
+ flights: await probe('flights', 'aaa002', flightCourse),
+ military: await probe('military', 'bbb101', militaryCourse),
};
});
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; ${arrivalAngleViolated(a) ? 'angle violation (not retried); ' : ''}${Number.isFinite(a.dMoveEnd) ? `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° ` : ''}${Number.isFinite(a.dReveal) ? `reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° ` : ''}elapsed=${Number(a.elapsedMs).toFixed(1)} ms attempts=${a.attempts}`
@@ -3780,24 +3814,13 @@ async function main() {
// (a) FLEET: retain the adversarial flags until this contact has passed
// through its installed fleet update and that frame has completed.
- const findFleetModel = (id) => {
- let found = null;
- const walk = (coll) => {
- const len = coll.length;
- for (let i = 0; i < len; i++) {
- let p; try { p = coll.get(i); } catch { continue; }
- if (!p) continue;
- if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
- if (p.activeAnimations !== undefined && p.minimumPixelSize !== undefined && p.id === id) found = p;
- }
- };
- walk(v.scene.primitives);
- return found;
- };
+ // The layer's own model handle: the engine's Model is drawn from it,
+ // and a flag planted on the engine's object never reaches the layer.
+ const fleetModelOf = (id) => fl.testing._aircraftHandlesForTest(id).model;
fl.setParams({ models3d: true });
const fleetDeadline = Date.now() + 20000;
let fleetModel = null;
- while (Date.now() < fleetDeadline && !(fleetModel = findFleetModel('aaa097'))) {
+ while (Date.now() < fleetDeadline && !(fleetModel = fleetModelOf('aaa097'))) {
await window.__dfSettle(500);
}
if (!fleetModel) {
@@ -3849,7 +3872,7 @@ async function main() {
return;
}
const currentBb = window.__dfFindBB('aaa097');
- const currentModel = findFleetModel('aaa097');
+ const currentModel = fleetModelOf('aaa097');
sample = {
fleetTickCompleted: true,
fleetFrame: v.scene.frameState.frameNumber,
@@ -3859,7 +3882,7 @@ async function main() {
fleetSeeded: seededFleet,
fleetH: currentBb ? window.__dfCarto(currentBb.position).h : null,
};
- if (currentBb !== bb || currentModel !== fleetModel || fleetModel.isDestroyed()
+ if (currentBb !== bb || currentModel !== fleetModel
|| fl.getTrackedInfo()) sample.error = 'fleet loading fixture changed before its completed frame';
});
armed = true;
@@ -4032,13 +4055,17 @@ async function main() {
const taxiMeshM = gfns?.cachedMeshFloor?.(taxiCarto.lat, taxiCarto.lon) ?? null;
const heldOwns = ns._driveFleetModelHandoffForTest({ icao24: holdIcao, position: taxiPos, course: 90 });
const heldH = window.__dfModelHeight(holdIcao);
- const heldBb = !!window.__dfFindBB(holdIcao)?.show;
+ const heldBb = !!window.__godsEyeView.layers
+ .drawn('flights')
+ .find((row) => row.id === holdIcao && !row.trackedModel)?.show;
// 3. ~385 m out the memory stops describing anywhere this contact has
// been. It is released rather than stretched, and the gate takes over.
const farPos = Cesium.Cartesian3.fromDegrees(base.lon + 0.004, base.lat, base.h);
const releasedOwns = ns._driveFleetModelHandoffForTest({ icao24: holdIcao, position: farPos, course: 90 });
- const releasedBb = !!window.__dfFindBB(holdIcao)?.show;
+ const releasedBb = !!window.__godsEyeView.layers
+ .drawn('flights')
+ .find((row) => row.id === holdIcao && !row.trackedModel)?.show;
fl.setParams({ models3d: false });
await window.__dfSettle(300);
diff --git a/scripts/vue-scripts.mjs b/scripts/vue-scripts.mjs
new file mode 100644
index 000000000..af7613131
--- /dev/null
+++ b/scripts/vue-scripts.mjs
@@ -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);
+}
diff --git a/src/annotations/navaraAnnotationRenderer.js b/src/annotations/navaraAnnotationRenderer.js
index ac2bc9f23..f2144cd3d 100644
--- a/src/annotations/navaraAnnotationRenderer.js
+++ b/src/annotations/navaraAnnotationRenderer.js
@@ -245,6 +245,7 @@ export function createNavaraWorldAnnotationRenderer(
export function createNavaraAnnotationRenderer(engine, views, { screen } = {}) {
const world = createNavaraWorldAnnotationRenderer(engine, views);
const renderer = createHybridAnnotationRenderer(null, {
+ engine,
world,
screenScene: screenSceneFromEngine(engine),
screen,
diff --git a/src/annotations/navaraAnnotationRenderer.test.mjs b/src/annotations/navaraAnnotationRenderer.test.mjs
index fe9eefbb3..c4a55666e 100644
--- a/src/annotations/navaraAnnotationRenderer.test.mjs
+++ b/src/annotations/navaraAnnotationRenderer.test.mjs
@@ -12,6 +12,7 @@ import {
createNavaraWorldAnnotationRenderer,
drapedParts,
} from './navaraAnnotationRenderer.js';
+import { createSceneServices } from '../data/sceneServices.js';
import { screenSceneFromEngine } from './screenScene.js';
for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) {
@@ -265,3 +266,39 @@ test('marks on the Navara board are drawn, horizon-culled and survive an engine
engine.fake.frame();
assert.equal(live().length, 0);
});
+
+test('screen annotations read the attached engines tracked card footprint', async (t) => {
+ const { store, groups } = board(t);
+ const engine = createFakeEngine({
+ longitude: 0,
+ latitude: 0,
+ altitude: 10000,
+ });
+ const services = createSceneServices(engine);
+ const queries = [];
+ services.overlay.getPaintRect = (...args) => {
+ queries.push(args);
+ return { x: 0, y: 0, w: 4000, h: 4000 };
+ };
+ const { views } = fakeViews();
+ const renderer = createNavaraAnnotationRenderer(engine, views, {
+ screen: { store, activeTrackedReadoutId: () => 'flight:test' },
+ });
+ t.after(() => {
+ renderer.destroy();
+ services.destroy();
+ engine.destroy();
+ });
+ renderer.add({
+ id: 'overlap',
+ type: 'label',
+ color: 'primary',
+ label: 'Overlap',
+ alpha: 1,
+ anchor: { lon: 0, lat: 0, height: 0 },
+ });
+ engine.fake.frame();
+ await settle();
+ assert.deepEqual(queries.at(-1), ['tracked', 'flight:test']);
+ assert.ok(Number(groups()[0].getAttribute('opacity')) < 1);
+});
diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js
index 3fe4246e7..92d0df8d4 100644
--- a/src/annotations/resolver.js
+++ b/src/annotations/resolver.js
@@ -2297,13 +2297,19 @@ export function createAnnotationResolver({
/**
* Whether a depth pick landed on the camera itself: something drawn into
* the depth buffer at the near plane (Navara's screen-space preview lines
- * and sprites) reads back as a point a metre or so from the camera. Such a
- * pick is a miss; the ray's globe hit answers instead.
+ * and sprites) reads back as a point near the camera, the nearer the lower
+ * the camera. Such a pick is a miss; the ray's globe hit answers instead.
+ * The scale is the rendered surface under the ray, not sea level, so a
+ * camera just above high terrain keeps its ground.
*/
function depthAtCamera(engine, screen, hit) {
const camera = engine.camera.livePosition;
- const ground = engine.camera.pickEllipsoid(screen);
- const reference = ground ? geo.cartesian3.distance(ground, camera) : 0;
+ let ground = engine.picker.pickGlobe(screen);
+ if (!isPickedWorldPosition(ground))
+ ground = engine.camera.pickEllipsoid(screen);
+ const reference = isPickedWorldPosition(ground)
+ ? geo.cartesian3.distance(ground, camera)
+ : 0;
return geo.cartesian3.distance(hit, camera) < Math.max(2, 0.01 * reference);
}
@@ -2340,8 +2346,8 @@ export function createAnnotationResolver({
const hit = engine.picker.pickPosition(screen);
return hit && !depthAtCamera(engine, screen, hit) ? hit : undefined;
},
- () => engine.camera.pickEllipsoid(screen),
() => engine.picker.pickGlobe(screen),
+ () => engine.camera.pickEllipsoid(screen),
];
for (const stage of stages) {
let cart = null;
diff --git a/src/annotations/resolverHighTerrain.test.mjs b/src/annotations/resolverHighTerrain.test.mjs
new file mode 100644
index 000000000..ba8e74825
--- /dev/null
+++ b/src/annotations/resolverHighTerrain.test.mjs
@@ -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);
+});
diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js
index 7f305a6d5..a4b77a885 100644
--- a/src/app/constructCatalog.js
+++ b/src/app/constructCatalog.js
@@ -5,7 +5,6 @@ import { createApplicationCyclones } from './layers/cyclones.js';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { rendererFor } from './layers/renderers.js';
-import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createApplicationFlights } from './layers/flights.js';
@@ -34,7 +33,7 @@ import { createApplicationGhostMaps } from './layers/ghostMaps.js';
import { createApplicationDroneAirspace } from './layers/droneAirspace.js';
import { createApplicationWildlifeSightings } from './layers/wildlifeSightings.js';
import { createApplicationCables } from './layers/submarineCables.js';
-import { OSM_OVERLAYS, createOsmOverlayLayers } from '../data/osmOverlays.js';
+import { createOsmOverlayLayers } from '../data/osmOverlays.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureSessionLayer } from '../layers/infrastructure/index.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
@@ -113,351 +112,214 @@ export function createApplicationCatalog({
});
}
}
- // Shared instances (awareness and launches read others) are made once,
- // when the first layer that needs them is built.
- const once = (create) => {
- let value;
- return () => (value ??= create());
- };
const sessionModel = (id, create) => {
if (!layerSession) return create();
const models = (layerSession.models ??= new Map());
if (!models.has(id)) models.set(id, create());
return models.get(id);
};
- const flights = once(() =>
- sessionModel('flights', () =>
- createApplicationFlights({
+ const flights = sessionModel('flights', () =>
+ createApplicationFlights({
+ surface,
+ source: sources.flights,
+ militaryRegistry,
+ resolveAsset,
+ }),
+ );
+ const military = sessionModel('military', () =>
+ createApplicationMilitary({
+ surface,
+ source: sources.military,
+ militaryRegistry,
+ resolveAsset,
+ }),
+ );
+ const vessels = sessionModel('ais-live-vessels', () =>
+ createApplicationVessels({
+ source: sources.vessels,
+ options: vesselOptions,
+ }),
+ );
+ const installations = sessionModel('military-installations', () =>
+ createApplicationInstallations({
+ surface,
+ source: sources.installations,
+ }),
+ );
+ const satellites = sessionModel('satellites', () =>
+ createApplicationSatellites({
+ source: sources.satellites,
+ }),
+ );
+ const layers = [
+ sessionModel('bhote-koshi-2026', () =>
+ createBhoteKoshiEventLayer({
+ createNativeRenderer: (engine) =>
+ rendererFor('bhote-koshi-2026', engine),
+ }),
+ ),
+ sessionModel('bhote-koshi-locator', () =>
+ createBhoteKoshiLocatorLayer({
+ boundaryResolver: nepalBoundaryResolver,
+ createNativeRenderer: (engine, data) =>
+ rendererFor('bhote-koshi-locator', engine, { data }),
+ }),
+ ),
+ flights,
+ military,
+ sessionModel('local-adsb', () =>
+ createApplicationLocalAdsb({
surface,
- source: sources.flights,
- militaryRegistry,
- resolveAsset,
+ // QA fixture receivers (?qaFeed=local-adsb:fixture, dev only).
+ ...(sources.localAdsbFeeds ? { feeds: sources.localAdsbFeeds } : {}),
+ enrichment: sources.flights,
+ displayParams: () => flights.getParams(),
+ ...(resolveAsset ? { resolveAsset } : {}),
}),
),
- );
- const military = once(() =>
- sessionModel('military', () =>
- createApplicationMilitary({
+ sessionModel('earthquakes', () =>
+ createApplicationEarthquakes({ source: sources.earthquakes }),
+ ),
+ sessionModel('air-quality', () =>
+ createApplicationAirQuality({ source: sources['air-quality'] }),
+ ),
+ sessionModel('fire-perimeters', () =>
+ createApplicationFirePerimeters({
+ source: sources['fire-perimeters'],
+ }),
+ ),
+ sessionModel('gnss-integrity', () =>
+ createApplicationGnssIntegrity({
+ source: sources['gnss-integrity'],
+ windowMs: sources['gnss-integrity'].windowMs,
+ }),
+ ),
+ sessionModel('s2-ghostmaps', () =>
+ createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
+ ),
+ sessionModel('drone-airspace', () =>
+ createApplicationDroneAirspace({
+ source: sources['drone-airspace'],
+ }),
+ ),
+ sessionModel('wildlife-sightings', () =>
+ createApplicationWildlifeSightings({
+ source: sources['wildlife-sightings'],
+ groundFloor: surface.groundFloor,
+ }),
+ ),
+ sessionModel('alpr-cameras', () =>
+ createApplicationAlpr({ surface, source: sources.alpr }),
+ ),
+ sessionModel('street-level', () =>
+ createApplicationStreetLevel({
surface,
- source: sources.military,
- militaryRegistry,
- resolveAsset,
+ nativeRenderer: (engine) => rendererFor('street-level', engine),
+ sources: { mapillary: sources.mapillary },
+ providers: streetLevelProviders,
}),
),
- );
- const vessels = once(() =>
- sessionModel('ais-live-vessels', () =>
- createApplicationVessels({
- source: sources.vessels,
- options: vesselOptions,
+ satellites,
+ sessionModel('rocket-launches', () =>
+ createApplicationLaunches({
+ source: sources.launches,
+ satellites: satellites,
}),
),
- );
- const installations = once(() =>
- sessionModel('military-installations', () =>
- createApplicationInstallations({
+ sessionModel('traffic', () =>
+ createApplicationTraffic({
+ source: sources.traffic,
surface,
- source: sources.installations,
}),
),
- );
- const satellites = once(() =>
- sessionModel('satellites', () =>
- createApplicationSatellites({
- source: sources.satellites,
+ sessionModel('cctv', () =>
+ createApplicationCctv({ surface, source: sources.cctv }),
+ ),
+ sessionModel('radio', () =>
+ createApplicationRadio({ surface, source: sources.radio }),
+ ),
+ sessionModel('transit', () =>
+ createApplicationTransit({ surface, source: sources.transit }),
+ ),
+ sessionModel('bikeshare', () =>
+ createApplicationBikeshare({ source: sources.bikeshare }),
+ ),
+ sessionModel('directions', () =>
+ createApplicationDirections({
+ source: sources.directions,
+ surface,
}),
),
- );
- // Catalog order: [layer id(s), construction]. Only the layers the
- // engine can show are constructed (renderers.js).
- const entries = [
- [
- 'bhote-koshi-2026',
- () =>
- sessionModel('bhote-koshi-2026', () =>
- createBhoteKoshiEventLayer({
- createNativeRenderer: (engine) =>
- rendererFor('bhote-koshi-2026', engine),
- }),
- ),
- ],
- [
- 'bhote-koshi-locator',
- () =>
- sessionModel('bhote-koshi-locator', () =>
- createBhoteKoshiLocatorLayer({
- boundaryResolver: nepalBoundaryResolver,
- createNativeRenderer: (engine, data) =>
- rendererFor('bhote-koshi-locator', engine, { data }),
- }),
- ),
- ],
- ['flights', flights],
- ['military', military],
- [
- 'local-adsb',
- () =>
- sessionModel('local-adsb', () =>
- createApplicationLocalAdsb({
- surface,
- // QA fixture receivers (?qaFeed=local-adsb:fixture, dev only).
- ...(sources.localAdsbFeeds
- ? { feeds: sources.localAdsbFeeds }
- : {}),
- enrichment: sources.flights,
- displayParams: () => flights().getParams(),
- ...(resolveAsset ? { resolveAsset } : {}),
- }),
- ),
- ],
- [
- 'earthquakes',
- () =>
- sessionModel('earthquakes', () =>
- createApplicationEarthquakes({ source: sources.earthquakes }),
- ),
- ],
- [
- 'air-quality',
- () =>
- sessionModel('air-quality', () =>
- createApplicationAirQuality({ source: sources['air-quality'] }),
- ),
- ],
- [
- 'fire-perimeters',
- () =>
- sessionModel('fire-perimeters', () =>
- createApplicationFirePerimeters({
- source: sources['fire-perimeters'],
- }),
- ),
- ],
- [
- 'gnss-integrity',
- () =>
- sessionModel('gnss-integrity', () =>
- createApplicationGnssIntegrity({
- source: sources['gnss-integrity'],
- windowMs: sources['gnss-integrity'].windowMs,
- }),
- ),
- ],
- [
- 's2-ghostmaps',
- () =>
- sessionModel('s2-ghostmaps', () =>
- createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
- ),
- ],
- [
- 'drone-airspace',
- () =>
- sessionModel('drone-airspace', () =>
- createApplicationDroneAirspace({
- source: sources['drone-airspace'],
- }),
- ),
- ],
- [
- 'wildlife-sightings',
- () =>
- sessionModel('wildlife-sightings', () =>
- createApplicationWildlifeSightings({
- source: sources['wildlife-sightings'],
- groundFloor: surface.groundFloor,
- }),
- ),
- ],
- [
- 'alpr-cameras',
- () =>
- sessionModel('alpr-cameras', () =>
- createApplicationAlpr({ surface, source: sources.alpr }),
- ),
- ],
- [
- 'street-level',
- () =>
- sessionModel('street-level', () =>
- createApplicationStreetLevel({
- surface,
- nativeRenderer: (engine) => rendererFor('street-level', engine),
- sources: { mapillary: sources.mapillary },
- providers: streetLevelProviders,
- }),
- ),
- ],
- ['satellites', satellites],
- [
- 'rocket-launches',
- () =>
- sessionModel('rocket-launches', () =>
- createApplicationLaunches({
- source: sources.launches,
- satellites: satellites(),
- }),
- ),
- ],
- [
- 'traffic',
- () =>
- sessionModel('traffic', () =>
- createApplicationTraffic({
- source: sources.traffic,
- surface,
- }),
- ),
- ],
- [
- 'cctv',
- () =>
- sessionModel('cctv', () =>
- createApplicationCctv({ surface, source: sources.cctv }),
- ),
- ],
- [
- 'radio',
- () =>
- sessionModel('radio', () =>
- createApplicationRadio({ surface, source: sources.radio }),
- ),
- ],
- [
- 'transit',
- () =>
- sessionModel('transit', () =>
- createApplicationTransit({ surface, source: sources.transit }),
- ),
- ],
- [
- 'bikeshare',
- () =>
- sessionModel('bikeshare', () =>
- createApplicationBikeshare({ source: sources.bikeshare }),
- ),
- ],
- [
- 'directions',
- () =>
- sessionModel('directions', () =>
- createApplicationDirections({
- source: sources.directions,
- surface,
- }),
- ),
- ],
- [
- 'recent-imagery',
- () =>
- sessionModel('recent-imagery', () =>
- createApplicationRecentImagery({
- source: sources['recent-imagery'],
- }),
- ),
- ],
- [
- 'osm-places',
- () => sessionModel('osm-places', () => createApplicationOsmPlaces()),
- ],
- ['ais-live-vessels', vessels],
- ['military-installations', installations],
- [
- 'military-awareness',
- () =>
- sessionModel('military-awareness', () =>
- createApplicationAwareness({
- flights: flights(),
- military: military(),
- vessels: vessels(),
- installations: installations(),
- }),
- ),
- ],
- [
- 'wind',
- () =>
- sessionModel('wind', () =>
- createWindLayer({
- feed: sources.wind,
- clock: weatherClock,
- createNativeRendering: (engine, options) =>
- rendererFor('wind', engine, options),
- }),
- ),
- ],
- ...['weather-radar', 'weather-satellite', 'weather-lightning'].map(
- (id) => [
- id,
- () =>
- sessionModel(id, () =>
- createWeatherLayer({
- feed: sources.weather,
- id,
- clock: weatherClock,
- createNativeRendering: (engine, options) =>
- rendererFor(id, engine, options),
+ sessionModel('recent-imagery', () =>
+ createApplicationRecentImagery({
+ source: sources['recent-imagery'],
+ }),
+ ),
+ sessionModel('osm-places', () => createApplicationOsmPlaces()),
+ vessels,
+ installations,
+ sessionModel('military-awareness', () =>
+ createApplicationAwareness({
+ flights: flights,
+ military: military,
+ vessels: vessels,
+ installations: installations,
+ }),
+ ),
+ sessionModel('wind', () =>
+ createWindLayer({
+ feed: sources.wind,
+ clock: weatherClock,
+ createNativeRendering: (engine, options) =>
+ rendererFor('wind', engine, options),
+ }),
+ ),
+ ...['weather-radar', 'weather-satellite', 'weather-lightning'].map((id) =>
+ sessionModel(id, () =>
+ createWeatherLayer({
+ feed: sources.weather,
+ id,
+ clock: weatherClock,
+ createNativeRendering: (engine, options) =>
+ rendererFor(id, engine, options),
+ }),
+ ),
+ ),
+ sessionModel('weather-cyclones', () =>
+ createApplicationCyclones({
+ feed: sources.cyclones,
+ createNativeRendering: (engine, options) =>
+ rendererFor('weather-cyclones', engine, options),
+ }),
+ ),
+ ...sessionModel('osm-overlays', () => createOsmOverlayLayers()),
+ ...sessionModel('local-infrastructure', () =>
+ createInfrastructureLayers(localGeoJsonServices, (config, services) =>
+ createInfrastructureSessionLayer(
+ config,
+ services,
+ (engine, descriptor, ops) =>
+ rendererFor(config.id, engine, {
+ config: descriptor,
+ services: ops,
}),
- ),
- ],
+ ),
+ ),
+ ),
+ sessionModel('telegeography-submarine-cables', () =>
+ createApplicationCables({ source: sources.cables, session: true }),
+ ),
+ sessionModel('local-firms', () =>
+ createApplicationFirms({
+ surface,
+ id: 'local-firms',
+ name: 'FIRMS Active Fires',
+ icon: '▲',
+ source: 'NASA FIRMS · LIVE',
+ feed: sources.firms,
+ }),
),
- [
- 'weather-cyclones',
- () =>
- sessionModel('weather-cyclones', () =>
- createApplicationCyclones({
- feed: sources.cyclones,
- createNativeRendering: (engine, options) =>
- rendererFor('weather-cyclones', engine, options),
- }),
- ),
- ],
- [
- OSM_OVERLAYS.map(({ id }) => id),
- () => sessionModel('osm-overlays', () => createOsmOverlayLayers()),
- ],
- [
- ['local-datacenters', 'local-dams'],
- () =>
- sessionModel('local-infrastructure', () =>
- createInfrastructureLayers(
- localGeoJsonServices,
- (config, services) =>
- createInfrastructureSessionLayer(
- config,
- services,
- (engine, descriptor, ops) =>
- rendererFor(config.id, engine, {
- config: descriptor,
- services: ops,
- }),
- ),
- ),
- ),
- ],
- [
- 'telegeography-submarine-cables',
- () =>
- sessionModel('telegeography-submarine-cables', () =>
- createApplicationCables({ source: sources.cables, session: true }),
- ),
- ],
- [
- 'local-firms',
- () =>
- sessionModel('local-firms', () =>
- createApplicationFirms({
- surface,
- id: 'local-firms',
- name: 'FIRMS Active Fires',
- icon: '▲',
- source: 'NASA FIRMS · LIVE',
- feed: sources.firms,
- }),
- ),
- ],
];
- const layers = entries.flatMap(([, create]) => create());
const catalog = createLayerCatalog(layers, metadata);
return Object.freeze({
...catalog,
diff --git a/src/app/controls.js b/src/app/controls.js
index 2706ee355..cb8c47988 100644
--- a/src/app/controls.js
+++ b/src/app/controls.js
@@ -46,8 +46,6 @@ export async function createApplicationControls({
const styleManager = new Controls(viewer, {
services: {
...sharedServices,
- // The catalog's layers; the ones this engine cannot show (Navara)
- // are empty stand-ins.
...catalogControlServices(catalog),
},
requestServices: operations.requests,
diff --git a/src/app/engineChoice.js b/src/app/engineChoice.js
index 95d7ce9c8..7d6b38ff7 100644
--- a/src/app/engineChoice.js
+++ b/src/app/engineChoice.js
@@ -8,6 +8,9 @@ export const ENGINE_STORAGE_KEY = 'gev.engine';
*/
export const ENGINE_CHOICES = Object.freeze(['cesium', 'navara', 'navara-hd']);
+/** The choice without one made, and the one a failed start falls back to. */
+export const DEFAULT_ENGINE_CHOICE = ENGINE_CHOICES[0];
+
/** The engine a choice runs: both Navara choices are the Navara engine. */
export const engineOfChoice = (choice) =>
choice === 'cesium' ? 'cesium' : 'navara';
@@ -21,12 +24,16 @@ const known = (value) => {
/**
* The globe choice for this page: `?engine=cesium|navara|navara-hd`, else
- * the stored `gev.engine`, else Cesium. Navara is experimental; an unknown or
+ * the stored `gev.engine`, else `fallback` (Cesium). Navara is experimental; an unknown or
* unreadable value never selects it.
- * @param {{ search?: string, storage?: { getItem(key: string): string | null } | null }} [where]
+ * @param {{ search?: string, storage?: { getItem(key: string): string | null } | null, fallback?: string }} [where]
* @returns {'cesium' | 'navara' | 'navara-hd'}
*/
-export function resolveEngineKind({ search = '', storage = null } = {}) {
+export function resolveEngineKind({
+ search = '',
+ storage = null,
+ fallback = DEFAULT_ENGINE_CHOICE,
+} = {}) {
const fromQuery = known(new URLSearchParams(search).get('engine'));
if (fromQuery) return fromQuery;
let stored = null;
@@ -35,5 +42,5 @@ export function resolveEngineKind({ search = '', storage = null } = {}) {
} catch {
// Storage can be blocked (private mode, sandboxed frames).
}
- return known(stored) ?? 'cesium';
+ return known(stored) ?? known(fallback) ?? DEFAULT_ENGINE_CHOICE;
}
diff --git a/src/app/engineVisitStorage.test.mjs b/src/app/engineVisitStorage.test.mjs
new file mode 100644
index 000000000..302746f24
--- /dev/null
+++ b/src/app/engineVisitStorage.test.mjs
@@ -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');
+});
diff --git a/src/app/layerSession.test.mjs b/src/app/layerSession.test.mjs
index 200332186..15ff8cc54 100644
--- a/src/app/layerSession.test.mjs
+++ b/src/app/layerSession.test.mjs
@@ -29,6 +29,7 @@ test('a session retains layer intentions through handoff but never after page te
entry,
enabled: true,
durable: true,
+ visit: false,
});
resumeSession(session);
assert.deepEqual(calls, ['suspend', 'viewport', 'resume']);
diff --git a/src/app/layers/aisLiveVessels.js b/src/app/layers/aisLiveVessels.js
index 1b25e446c..078379188 100644
--- a/src/app/layers/aisLiveVessels.js
+++ b/src/app/layers/aisLiveVessels.js
@@ -12,7 +12,6 @@ import * as selection from '../../data/selectionDispatcher.js';
import { rendererFor } from './renderers.js';
import * as readouts from '../../hudReadouts.js';
-/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationVessels({
source,
options = {},
diff --git a/src/app/layers/alprCameras.js b/src/app/layers/alprCameras.js
index 1465f665b..c996eb226 100644
--- a/src/app/layers/alprCameras.js
+++ b/src/app/layers/alprCameras.js
@@ -9,11 +9,6 @@ import * as picking from '../../data/pickRegistry.js';
import { keyholeLabelAlphaFromGeometry } from '../../celestialRing.js';
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
-/**
- * Construct one layer using the application scene owners and a supplied
- * source; it draws with the engine's renderer from the manifest
- * (render/display.js on every engine).
- */
export function createApplicationAlpr({
surface,
source,
diff --git a/src/app/layers/bikeshare.js b/src/app/layers/bikeshare.js
index ba899c487..bb92eac9e 100644
--- a/src/app/layers/bikeshare.js
+++ b/src/app/layers/bikeshare.js
@@ -4,11 +4,6 @@ import * as render from '../../renderGovernor.js';
import * as picking from '../../data/pickRegistry.js';
import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js';
-/**
- * Construct one layer using the application scene owners and a supplied
- * source; it draws with the engine's renderer from the manifest
- * (render/display.js on every engine).
- */
export function createApplicationBikeshare({ source }) {
return createBikeshareLayer({
source,
diff --git a/src/app/layers/cctv.js b/src/app/layers/cctv.js
index ffb9909af..ec09b0bdc 100644
--- a/src/app/layers/cctv.js
+++ b/src/app/layers/cctv.js
@@ -8,11 +8,6 @@ import * as picking from '../../data/pickRegistry.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as render from '../../renderGovernor.js';
-/**
- * Construct one layer using the application scene owners and a supplied
- * source; it draws with the engine's renderer from the manifest
- * (render/display.js on every engine).
- */
export function createApplicationCctv({
surface,
source,
diff --git a/src/app/layers/flights.js b/src/app/layers/flights.js
index d3d942b57..943d977d3 100644
--- a/src/app/layers/flights.js
+++ b/src/app/layers/flights.js
@@ -12,7 +12,6 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as recession from '../../data/aircraftRecession.js';
-/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationFlights({
surface,
source,
diff --git a/src/app/layers/militaryFlights.js b/src/app/layers/militaryFlights.js
index 0e58266a8..fce784d39 100644
--- a/src/app/layers/militaryFlights.js
+++ b/src/app/layers/militaryFlights.js
@@ -12,7 +12,6 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as recession from '../../data/aircraftRecession.js';
-/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationMilitary({
surface,
source,
diff --git a/src/app/layers/militaryInstallations.js b/src/app/layers/militaryInstallations.js
index 3fc78cd02..8cd558e36 100644
--- a/src/app/layers/militaryInstallations.js
+++ b/src/app/layers/militaryInstallations.js
@@ -6,11 +6,6 @@ import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js';
import * as picking from '../../data/pickRegistry.js';
-/**
- * Construct one layer using the application scene owners and a supplied
- * source; it draws with the engine's renderer from the manifest
- * (render/display.js on every engine).
- */
export function createApplicationInstallations({ surface, source }) {
const { groundFloor: ground, anchors } = surface;
return createInstallationsLayer({
diff --git a/src/app/layers/qaSources.js b/src/app/layers/qaSources.js
index 616c97172..c16c4d824 100644
--- a/src/app/layers/qaSources.js
+++ b/src/app/layers/qaSources.js
@@ -31,6 +31,9 @@ import { satelliteFixture } from '../../bench/satelliteFixture.js';
import { createSatelliteSource } from '../../layers/satellites/source.js';
import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js';
+// The largest synthetic fleet used by the QA scenarios.
+const MAX_QA_COUNT = 10_000;
+
const KINDS = Object.freeze({
flights: new Set(['synthetic']),
military: new Set(['synthetic']),
@@ -49,7 +52,8 @@ export function parseQaFeed(search = '') {
const count = rawCount === undefined ? null : Number(rawCount);
if (
!KINDS[layer]?.has(kind) ||
- (count !== null && !(Number.isInteger(count) && count > 0))
+ (count !== null &&
+ !(Number.isSafeInteger(count) && count > 0 && count <= MAX_QA_COUNT))
)
throw new TypeError(
`qaFeed: unknown entry "${item}" (flights:synthetic[:count], military:synthetic[:count], vessels:fixture, satellites:fixture, launches:fixture, local-adsb:fixture)`,
diff --git a/src/app/layers/qaSources.test.mjs b/src/app/layers/qaSources.test.mjs
index 372e931df..7c98ac17f 100644
--- a/src/app/layers/qaSources.test.mjs
+++ b/src/app/layers/qaSources.test.mjs
@@ -133,3 +133,19 @@ test('vessel fixture answers a relative track URL and retains its full snapshot'
});
assert.equal((await vessels.getSnapshot({})).records.length, 12000);
});
+
+test('fixture counts cannot exceed the QA fleet budget', () => {
+ for (const count of [
+ '10001',
+ '1e9',
+ '9007199254740992',
+ 'Infinity',
+ '0',
+ '1.5',
+ ])
+ assert.throws(
+ () => parseQaFeed(`?qaFeed=flights:synthetic:${count}`),
+ TypeError,
+ );
+ assert.equal(parseQaFeed('?qaFeed=flights:synthetic:10000')[0].count, 10000);
+});
diff --git a/src/app/layers/radio.js b/src/app/layers/radio.js
index de1c6e7b7..d28096469 100644
--- a/src/app/layers/radio.js
+++ b/src/app/layers/radio.js
@@ -5,11 +5,6 @@ import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js';
import * as globe from '../../celestialRing.js';
import * as render from '../../renderGovernor.js';
-/**
- * Construct one layer using the application scene owners and a supplied
- * source; it draws with the engine's renderer from the manifest
- * (render/display.js on every engine).
- */
export function createApplicationRadio({
surface,
source,
diff --git a/src/app/layers/rocketLaunches.js b/src/app/layers/rocketLaunches.js
index 0bf7e371f..ddc6255e8 100644
--- a/src/app/layers/rocketLaunches.js
+++ b/src/app/layers/rocketLaunches.js
@@ -4,7 +4,6 @@ import * as geometry from '../../celestialRing.js';
import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js';
import * as render from '../../renderGovernor.js';
-/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationLaunches({
source,
satellites,
diff --git a/src/app/layers/satellites.js b/src/app/layers/satellites.js
index e613a9c2f..7282e10bd 100644
--- a/src/app/layers/satellites.js
+++ b/src/app/layers/satellites.js
@@ -8,7 +8,6 @@ import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as layerState from '../../data/layerState.js';
-/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationSatellites({
source,
// Test seam: the renderer factory (default: the engine's, from the
diff --git a/src/app/layers/traffic.js b/src/app/layers/traffic.js
index b999c6757..63c0e5bbe 100644
--- a/src/app/layers/traffic.js
+++ b/src/app/layers/traffic.js
@@ -4,11 +4,6 @@ import * as ground from '../../data/groundFloor.js';
import * as render from '../../renderGovernor.js';
import { rendererFor } from './renderers.js';
-/**
- * Construct one layer using the application scene owners and a supplied
- * source; it draws with the engine's renderer from the manifest
- * (render/display.js on every engine).
- */
export function createApplicationTraffic({ source, surface }) {
return createTrafficLayer({
source,
diff --git a/src/app/loadCesium.js b/src/app/loadCesium.js
index f3589e0b1..c7fb68c90 100644
--- a/src/app/loadCesium.js
+++ b/src/app/loadCesium.js
@@ -7,7 +7,7 @@
// - `viewer()`: a Cesium globe is about to start: the SDK and the widget
// styles;
// - `sdk()`: a feature that still builds on Cesium's entity model (the Nepal
-// event and locator, imported KML) is used on Navara: the SDK alone.
+// event and locator) is used on Navara: the SDK alone.
export function createCesiumLoader({
document = globalThis.document,
globals = globalThis,
@@ -45,6 +45,9 @@ export function createCesiumLoader({
const ready = () =>
!script ||
Object.getOwnPropertyDescriptor(globals, 'Cesium')?.value != null;
+ // A page whose markup ran Cesium.js before this loader also carries its
+ // widget styles (the MCP panel build, which cannot load files by URL).
+ const preloaded = script && ready();
const sdk = () => {
if (ready()) return Promise.resolve();
sdkPending ??= load('script', `${root()}Cesium.js`).catch((error) => {
@@ -54,6 +57,7 @@ export function createCesiumLoader({
return sdkPending;
};
const styles = () => {
+ if (preloaded) return Promise.resolve();
stylesPending ??= load('link', `${root()}Widgets/widgets.css`).catch(
(error) => {
stylesPending = null;
diff --git a/src/app/loadCesium.test.mjs b/src/app/loadCesium.test.mjs
index 1b91861a9..475417e3f 100644
--- a/src/app/loadCesium.test.mjs
+++ b/src/app/loadCesium.test.mjs
@@ -76,6 +76,14 @@ test('the SDK is not loaded where the page already has it or imports its module'
assert.equal(dev.elements.length, 0);
});
+test('a page that ran Cesium.js from its markup loads no styles either', async () => {
+ // The panel build's HTML lists Cesium.js and widgets.css; the panel host
+ // answers only those, not a stylesheet added later.
+ const { elements, loader } = fixture({ Cesium: {} });
+ await loader.viewer();
+ assert.equal(elements.length, 0);
+});
+
test('a failed Cesium asset load can be retried and preserves the configured base', async () => {
const { elements, globals, loader } = fixture({
CESIUM_BASE_URL: '/custom/',
diff --git a/src/app/mainStartup.test.mjs b/src/app/mainStartup.test.mjs
new file mode 100644
index 000000000..5cd0e1635
--- /dev/null
+++ b/src/app/mainStartup.test.mjs
@@ -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 =
+ '
';
+ 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',
+ ]);
+});
diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js
index 074d0bb50..9fcc7435f 100644
--- a/src/app/navaraMode.js
+++ b/src/app/navaraMode.js
@@ -9,6 +9,7 @@ import { registerDataCredits } from '../data/dataCredits.js';
import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js';
import { LAYER_MANIFEST } from './layers/renderers.js';
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js';
+import { localizeLayerName } from '../ui/layerNames.js';
/** The Navara globe module, loaded on demand. */
const loadNavaraGlobeModule = async () =>
@@ -26,8 +27,10 @@ export function navaraModeNotice(manifest = LAYER_MANIFEST) {
return Object.freeze({
label: t('Experimental Navara engine'),
detail: names.length
- ? `Data layers: ${names.join(', ')}`
- : 'No registered data layers',
+ ? t('Data layers: {layers}', {
+ layers: names.map(localizeLayerName).join(', '),
+ })
+ : t('No registered data layers'),
});
}
@@ -101,7 +104,7 @@ export async function createNavaraScene({
/**
* The Navara tool backend (src/app/tools.js) of one Navara scene: where the
- * annotation board and Draw draw (on the engine and Navara's view; null
+ * annotation board draws (on the engine and Navara's view; null
* without the view), the Recent Imagery panel's globe, the render
* governor on the engine, and this engine's own parts: the daylight of a
* restored view and the engine notice.
@@ -115,8 +118,6 @@ export function navaraToolBackend({ engine }) {
drawn
? { engine, renderer: createNavaraAnnotationRenderer(engine, views) }
: null,
- // The session's tool on the engine's input and preview.
- draw: () => ({ engine }),
recentImagery: () => ({ engine }),
attach({ styleManager }, { signal, defer }) {
void styleManager.initialRestorePromise?.then(() => {
diff --git a/src/app/tools.js b/src/app/tools.js
index bba25c22e..51ddbb9da 100644
--- a/src/app/tools.js
+++ b/src/app/tools.js
@@ -272,7 +272,6 @@ function cesiumToolBackend({ engine, tileset }) {
viewer,
startupChrome: {},
board: () => ({ viewer, engine, tileset }),
- draw: () => ({ engine }),
// The tileset lets the layer drape while the globe is hidden.
recentImagery(layer) {
layer.attachTileset(tileset);
@@ -353,7 +352,7 @@ async function attachEngineTools(
{ layerSession, placeSearch, operations },
defer,
);
- attachDraw(backend.draw, { layerSession, annotations, styleManager }, defer);
+ attachDraw(engine, { layerSession, annotations, styleManager }, defer);
// Import draws through the globe contract, the same on every engine.
attachGeoImport(
{ engine },
@@ -483,13 +482,13 @@ function attachBoard(
}
/**
- * UTILITIES ▸ Draw on this globe (`drawScene()`: its input and preview):
+ * UTILITIES ▸ Draw on this globe (its engine input and preview):
* the tool claims the globe's pointer while a sketch is open, so a switch
* ends an open sketch and keeps the chosen shape and colour. Returns the
* tool (null on a page without it or without the board).
*/
function attachDraw(
- drawScene,
+ engine,
{ layerSession, annotations, styleManager },
defer,
) {
@@ -500,7 +499,7 @@ function attachDraw(
styleManager,
defer,
});
- drawTool?.attachScene({ ...drawScene(), navigation: styleManager });
+ drawTool?.attachScene({ engine, navigation: styleManager });
defer(() => drawTool?.detachScene());
return drawTool;
}
diff --git a/src/celestialRing.js b/src/celestialRing.js
index de7797041..eb240b569 100644
--- a/src/celestialRing.js
+++ b/src/celestialRing.js
@@ -410,6 +410,7 @@ export class CelestialRing {
viewer,
{ enabled = true, onAutoDisable = null, store, engine = null } = {},
) {
+ this._engine = engine ?? (viewer?.kind ? viewer : null);
this._engineView = viewer?.kind ? createCelestialView(viewer) : null;
this.viewer = this._engineView?.view ?? viewer;
this._projector = engine?.projector ?? null;
@@ -603,7 +604,7 @@ export class CelestialRing {
this._focusInProgress = false;
this.viewer.scene.requestRender?.();
};
- flyCelestialViewer(this.viewer, {
+ const flight = {
destination: geo.cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
@@ -620,7 +621,10 @@ export class CelestialRing {
finishFocus();
if (!this.isGlobeFullyVisible()) this._autoDisable();
},
- });
+ };
+ if (this._engine)
+ this._engine.camera.flyTo({ ...flight, easing: 'cubicInOut' });
+ else flyCelestialViewer(this.viewer, flight);
return true;
}
diff --git a/src/celestialRingFocus.test.mjs b/src/celestialRingFocus.test.mjs
new file mode 100644
index 000000000..382d5c8ef
--- /dev/null
+++ b/src/celestialRingFocus.test.mjs
@@ -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);
+});
diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js
index b99b07483..19ff16b81 100644
--- a/src/cockpitCloudEffects.js
+++ b/src/cockpitCloudEffects.js
@@ -662,9 +662,7 @@ class CockpitCloudEffectsController {
}
/**
- * Free the GPU objects and the canvas's WebGL context. The application is
- * rebuilt on every globe switch, and browsers cap live contexts, so a
- * destroyed controller must not leave its context for garbage collection.
+ * Free GPU objects and the canvas's WebGL context: browsers cap live contexts.
*/
releaseRenderer() {
const gl = this.gl;
diff --git a/src/cyberSonarEngine.js b/src/cyberSonarEngine.js
index 1598c1403..3d80c72d4 100644
--- a/src/cyberSonarEngine.js
+++ b/src/cyberSonarEngine.js
@@ -1,6 +1,6 @@
// The Cyber HUD sonar's contact treatment on the globe engine, which draws
// it (render.setContactSonar, the `contactSonar` capability: Navara's
-// sprite and point movers, Cesium's native draw commands and models). Each
+// movers, Cesium's native draw commands and models). Each
// drawn frame hands the engine the theme's state, the sweep's settings and
// its angle on the shared clock (the CSS sweep's), so the contacts and the
// rings turn together; while the sweep runs over visible contacts a parked
@@ -36,12 +36,9 @@ export function cyberSonarEngineState(
};
}
-/** Sprite and point instances the engine draws (models keep their colours). */
+/** Every mover instance the engine holds, on screen or not. */
const contactCount = (engine) =>
- engine.movers
- .list()
- .filter((set) => set.kind !== 'model')
- .reduce((count, set) => count + set.count, 0);
+ engine.movers.list().reduce((count, set) => count + set.count, 0);
const UNSUPPORTED_MESSAGE =
'Contact GPU unavailable. Native contacts remain visible.';
@@ -85,12 +82,13 @@ export function createCyberSonarEngineScene(
if (isCyberSonarActive({ root })) syncCyberSonarAnimation(root);
engine.render.setContactSonar(cyberSonarEngineState(now(), root));
});
- // Contacts the sweep passes over: the engine's movers, and what its own
- // treatment drew (Cesium's tinted models and visible native batches).
+ // Contacts the sweep passes over on screen, as the engine's treatment
+ // reports them (Navara's movers in view; Cesium's tinted models and
+ // visible native batches); an engine without a report, all its movers.
const contactsShown = () => {
- if (contactCount(engine)) return true;
const drawn = diagnostics();
- return Boolean(drawn?.contacts || drawn?.gpu?.visibleCommands);
+ if (!drawn) return contactCount(engine) > 0;
+ return Boolean(drawn.contacts || drawn.gpu?.visibleCommands);
};
const removePostRender = engine.frames.postRender.addEventListener(() => {
// A treatment lost while the scene is parked (Sonar OFF, reduced
diff --git a/src/cyberSonarEngine.test.mjs b/src/cyberSonarEngine.test.mjs
index 402288634..adf8d62df 100644
--- a/src/cyberSonarEngine.test.mjs
+++ b/src/cyberSonarEngine.test.mjs
@@ -166,3 +166,36 @@ test("an engine's own contacts keep the sweep drawing, and a lost treatment wake
dispose();
assert.equal(root.dataset.cyberSonarGpu, undefined);
}));
+
+test('contacts held off screen leave the sweep idle', () =>
+ withCyberPage(async ({ root }) => {
+ const engine = createFakeEngine({ kind: 'navara', contactSonar: true });
+ // Navara's report (createNavaraEngine.js): the movers in view.
+ const report = { supported: true, contacts: 0 };
+ engine.contactSonarDiagnostics = () => report;
+ const timers = new Map();
+ let nextTimer = 1;
+ const dispose = createCyberSonarEngineScene(engine, null, {
+ root,
+ setTimer: (callback, ms) => {
+ timers.set(nextTimer, [callback, ms]);
+ return nextTimer++;
+ },
+ clearTimer: (id) => timers.delete(id),
+ });
+ const set = engine.movers.createSet({
+ id: 'qa-contacts',
+ kind: 'point',
+ capacity: 1,
+ point: { sizePx: 8 },
+ });
+ set.set(0, { position: { x: 6378137, y: 0, z: 0 } });
+ set.setCount(1);
+ set.commit();
+ engine.fake.frame();
+ assert.equal(timers.size, 0, 'a contact out of view draws nothing');
+ report.contacts = 1;
+ engine.fake.frame();
+ assert.equal(timers.size, 1, 'one in view keeps the sweep moving');
+ dispose();
+ }));
diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js
index 5748358fa..355733ee4 100644
--- a/src/data/bhoteKoshiEvent.js
+++ b/src/data/bhoteKoshiEvent.js
@@ -44,6 +44,8 @@ const SCENE_REVEAL_RENDER_HOLD = 'bhote-koshi-scene-beat-reveal';
const SCENE_PATH_RENDER_HOLD = 'bhote-koshi-scene-path-travel';
const SCENE_MEDIA_MIN_PLAYBACK_SECONDS = 6;
const SCENE_MEDIA_EXIT_SECONDS = 0.65;
+// A camera this near an evidence anchor has the valley's terrain loaded.
+const EVIDENCE_REFINE_M = 25_000;
const BHOTE_EVENT_ACCENT = WORLD_OVERLAY_STYLE.accent;
const FLOOD_COLOR = () =>
Cesium.Color.fromCssColorString('#d8954e').withAlpha(0.68);
@@ -1050,6 +1052,9 @@ export function createBhoteKoshiEventLayer({
let _evidenceTimeline = [];
let _evidenceAnchors = [];
let _evidenceFrameSlots = [];
+ let _evidenceEntries = [];
+ // Evidence anchors at the pack's elevations (no terrain sample).
+ const _fallbackAnchors = new WeakSet();
let _evidenceVideo = null;
let _evidenceVideoIndex = -1;
let _evidenceVideoReady = false;
@@ -1198,14 +1203,17 @@ export function createBhoteKoshiEventLayer({
: [];
let sampled = null;
try {
- sampled =
- _engine && !_viewer
- ? observations.map(({ lon, lat }) => ({
- height: _engine.surface.terrainHeight(
- Cesium.Cartographic.fromDegrees(lon, lat),
- ),
- }))
- : await terrainSampler(_viewer, observations, { signal });
+ if (_engine && !_viewer) {
+ const revision = _engine.maps.terrainRevision();
+ const heights = await _engine.surface.sampleTerrainMostDetailed(
+ observations.map(({ lon, lat }) =>
+ Cesium.Cartographic.fromDegrees(lon, lat),
+ ),
+ );
+ // Heights of a terrain replaced meanwhile are not the one shown.
+ if (_engine.maps.terrainRevision() === revision)
+ sampled = heights.map((height) => ({ height }));
+ } else sampled = await terrainSampler(_viewer, observations, { signal });
} catch (error) {
if (error?.name === 'AbortError') throw error;
console.warn(
@@ -1217,14 +1225,35 @@ export function createBhoteKoshiEventLayer({
return observations.map((observation, index) => {
const sampledHeight = Number(sampled?.[index]?.height);
const fallbackHeight = Number(observation.fallbackElevationM) || 0;
- return Cesium.Cartesian3.fromDegrees(
+ const anchor = Cesium.Cartesian3.fromDegrees(
observation.lon,
observation.lat,
(Number.isFinite(sampledHeight) ? sampledHeight : fallbackHeight) + 18,
);
+ if (!Number.isFinite(sampledHeight)) _fallbackAnchors.add(anchor);
+ return anchor;
});
}
+ // An anchor at the pack's elevation, on Navara, moves onto the terrain
+ // loaded under a near camera (the valley's, not an overview tile's), in
+ // place, so the card's cull position follows.
+ function refineEvidenceAnchor(index) {
+ const anchor = _evidenceAnchors[index];
+ if (!anchor || _viewer || !_engine || !_fallbackAnchors.has(anchor))
+ return anchor || null;
+ const live = _engine.camera.livePosition;
+ if (!live || Cesium.Cartesian3.distance(live, anchor) > EVIDENCE_REFINE_M)
+ return anchor;
+ const { lon, lat } = _evidenceTimeline[index]?.observation ?? {};
+ const height = Number.isFinite(lon)
+ ? _engine.surface.terrainHeight(Cesium.Cartographic.fromDegrees(lon, lat))
+ : undefined;
+ if (Number.isFinite(height))
+ Cesium.Cartesian3.fromDegrees(lon, lat, height + 18, undefined, anchor);
+ return anchor;
+ }
+
function evidencePositionGetter(index, breadcrumb = false) {
return () => {
if (!_enabled) return null;
@@ -1245,13 +1274,13 @@ export function createBhoteKoshiEventLayer({
_sceneMediaPlaybackCompletedBeatId
)
return null;
- return _evidenceAnchors[index] || null;
+ return refineEvidenceAnchor(index);
}
const activeIndex = activeEvidenceIndex(_progress, _evidenceTimeline);
if (breadcrumb ? index >= activeIndex : index !== activeIndex)
return null;
if (!breadcrumb && index === _embeddedEvidenceIndex) return null;
- return _evidenceAnchors[index] || null;
+ return refineEvidenceAnchor(index);
};
}
@@ -2448,7 +2477,12 @@ export function createBhoteKoshiEventLayer({
placement: 'above',
});
}
- overlayHost.setEntries(BHOTE_KOSHI_OVERLAY_SOURCE_ID, entries, {
+ _evidenceEntries = entries;
+ restoreEvidenceEntries();
+ }
+
+ function restoreEvidenceEntries() {
+ overlayHost.setEntries(BHOTE_KOSHI_OVERLAY_SOURCE_ID, _evidenceEntries, {
cohortLimit: 16,
collisionCapacity: 12,
moving: false,
@@ -3557,6 +3591,7 @@ export function createBhoteKoshiEventLayer({
return;
}
createSplitControl(_event);
+ restoreEvidenceEntries();
syncPresentationMode();
attachCameraInputRelease();
attachEvidenceCardActivation();
@@ -3739,6 +3774,7 @@ export function createBhoteKoshiEventLayer({
_corridorPositionCache = { visibleCount: -1, positions: [] };
_evidenceTimeline = [];
_evidenceAnchors = [];
+ _evidenceEntries = [];
_cinematicKeyframes = [];
for (const slot of _evidenceFrameSlots) {
closeFrame(slot.frame);
diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs
index 19e6aff9b..d9146e7b3 100644
--- a/src/data/bhoteKoshiEvent.test.mjs
+++ b/src/data/bhoteKoshiEvent.test.mjs
@@ -3133,3 +3133,182 @@ test('Bhote Koshi stops waiting for a stalled map switch when its scene is relea
globalThis.window = originalWindow;
}
});
+
+test('Bhote Koshi republishes its evidence cards to the incoming engine overlay', async () => {
+ const event = JSON.parse(await readFile(eventUrl, 'utf8'));
+ const originalDocument = globalThis.document;
+ const originalWindow = globalThis.window;
+ installEventDom();
+ globalThis.window = { setTimeout: () => 1, clearTimeout() {} };
+ const first = eventViewer();
+ const second = eventViewer();
+ const engineB = {
+ id: 'B',
+ host: { canvas: second.scene.canvas },
+ camera: second.camera,
+ };
+ const hosts = new Map();
+ const hostFor = (engine) => {
+ const key = engine ?? 'A';
+ if (!hosts.has(key))
+ hosts.set(key, {
+ entries: new Map(),
+ setEntries(id, e) {
+ this.entries.set(id, e);
+ },
+ setVisible() {},
+ clearSource(id) {
+ this.entries.delete(id);
+ },
+ hitTest() {
+ return null;
+ },
+ });
+ return hosts.get(key);
+ };
+ const layer = createBhoteKoshiEventLayer({
+ eventLoader: async () => event,
+ imageLoader: async (url) => ({ url }),
+ embeddedMediaFactory: () => ({
+ show() {},
+ hide() {},
+ pause() {},
+ destroy() {},
+ }),
+ overlayHost: hostFor,
+ renderHost: { request() {}, hold() {}, release() {} },
+ });
+ layer.attachMapStackController({
+ getActiveId: () => 'esri-imagery',
+ getSwitchGeneration: () => 1,
+ async setStack() {},
+ });
+ const manager = new LayerLifecycle(first);
+ manager.register(layer);
+ try {
+ assert.equal(
+ await manager.setEnabled(layer.id, true, { origin: 'local-restore' }),
+ true,
+ );
+ const a = hostFor(null);
+ assert.ok(
+ [...a.entries.values()].some((e) => e.length > 0),
+ 'first engine got the cards',
+ );
+ await layer.detachRenderer();
+ await layer.attachRenderer(second, { engine: engineB });
+ const b = hostFor(engineB);
+ assert.ok(
+ [...b.entries.values()].some((e) => e.length > 0),
+ 'incoming engine overlay should get the cards',
+ );
+ } finally {
+ await manager.destroyLayer(layer.id);
+ globalThis.document = originalDocument;
+ globalThis.window = originalWindow;
+ }
+});
+
+// The event on a Navara engine double: its surface samples the terrain at
+// its finest level (or fails to), and its loaded terrain answers `terrain`.
+async function navaraEvidenceCard({ sample }) {
+ const event = JSON.parse(await readFile(eventUrl, 'utf8'));
+ installEventDom();
+ globalThis.window = { setTimeout: () => 1, clearTimeout() {} };
+ const entries = [];
+ const engine = {
+ terrain: 3500,
+ host: { canvas: eventViewer().scene.canvas },
+ surface: {
+ terrainHeight: () => engine.terrain,
+ sampleTerrainMostDetailed: sample,
+ },
+ maps: { terrainRevision: () => 1 },
+ camera: { livePosition: Cesium.Cartesian3.fromDegrees(0, 0, 1e7) },
+ };
+ const layer = buildEventLayer({
+ eventLoader: async () => event,
+ imageLoader: async (url) => ({ url }),
+ createNativeRenderer: () => ({
+ loadImages: async () => {},
+ draw: () => ({ destroy() {} }),
+ setVisible() {},
+ setSplit() {},
+ clear() {},
+ destroy() {},
+ }),
+ embeddedMediaFactory: () => ({
+ show() {},
+ hide() {},
+ pause() {},
+ destroy() {},
+ }),
+ overlayHost: {
+ setEntries: (_id, value) => entries.push(...value),
+ setVisible() {},
+ clearSource() {},
+ },
+ renderHost: { request() {}, hold() {}, release() {} },
+ });
+ await layer.init(null, { engine });
+ await layer.enable(null, { origin: 'local-restore' });
+ const [first] = event.evidenceSpine;
+ const card = entries.find(
+ (entry) => entry.id === `evidence-card-${first.id}`,
+ );
+ return {
+ engine,
+ layer,
+ card,
+ first,
+ height: () => Cesium.Cartographic.fromCartesian(card.cullPosition).height,
+ };
+}
+
+test('Navara evidence anchors stand on the most detailed terrain', async () => {
+ const originalDocument = globalThis.document;
+ const originalWindow = globalThis.window;
+ const { layer, height } = await navaraEvidenceCard({
+ sample: async (places) => places.map(() => 1234),
+ });
+ try {
+ assert.ok(Math.abs(height() - 1252) < 0.01, `${height()}`);
+ } finally {
+ await layer.disable();
+ await layer.destroy();
+ globalThis.document = originalDocument;
+ globalThis.window = originalWindow;
+ }
+});
+
+test('Navara evidence anchors without a terrain sample follow the terrain near the camera', async () => {
+ const originalDocument = globalThis.document;
+ const originalWindow = globalThis.window;
+ const { engine, layer, card, first, height } = await navaraEvidenceCard({
+ sample: async () => {
+ throw new Error('no terrain');
+ },
+ });
+ try {
+ const pack = first.fallbackElevationM + 18;
+ assert.ok(Math.abs(height() - pack) < 0.01, 'the pack elevation');
+ card.position();
+ assert.ok(Math.abs(height() - pack) < 0.01, 'a far camera keeps it');
+ engine.camera.livePosition = Cesium.Cartesian3.fromDegrees(
+ first.lon,
+ first.lat,
+ 6000,
+ );
+ engine.terrain = 2000;
+ assert.ok(card.position(), 'the first card is shown');
+ assert.ok(
+ Math.abs(height() - 2018) < 0.01,
+ 'a near camera reads the terrain',
+ );
+ } finally {
+ await layer.disable();
+ await layer.destroy();
+ globalThis.document = originalDocument;
+ globalThis.window = originalWindow;
+ }
+});
diff --git a/src/data/cameraArrival.test.mjs b/src/data/cameraArrival.test.mjs
index 544eb80c2..96d9a8055 100644
--- a/src/data/cameraArrival.test.mjs
+++ b/src/data/cameraArrival.test.mjs
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
+import { LayerLifecycle } from './lifecycle.js';
import { cancelCameraArrival, ownCameraArrival } from './cameraArrival.js';
test('an arrival owned on the engine yields to a cancel on the engine', () => {
@@ -33,3 +34,29 @@ test('a newer arrival replaces the older one and release is idempotent', () => {
cancelCameraArrival(null);
assert.deepEqual(seen, ['old', 'new']);
});
+
+test('disabling a layer cancels an engine arrival without a native viewer', async () => {
+ const engine = createFakeEngine();
+ const manager = new LayerLifecycle(null, { engine });
+ manager.register({
+ id: 'arrival',
+ name: 'Arrival',
+ init() {},
+ enable() {},
+ disable() {},
+ update() {},
+ updateInterval: -1,
+ });
+ let cancelled = 0;
+ const release = ownCameraArrival(engine, () => {
+ cancelled++;
+ });
+ try {
+ await manager.setEnabled('arrival', false);
+ assert.equal(cancelled, 1);
+ } finally {
+ release();
+ await manager.destroyLayer('arrival');
+ engine.destroy();
+ }
+});
diff --git a/src/data/geoFileImportDegenerate.test.mjs b/src/data/geoFileImportDegenerate.test.mjs
new file mode 100644
index 000000000..b0cc9d814
--- /dev/null
+++ b/src/data/geoFileImportDegenerate.test.mjs
@@ -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),
+ );
+ });
+}
diff --git a/src/data/geoImportDisplay.js b/src/data/geoImportDisplay.js
index e8823d633..78e6edd95 100644
--- a/src/data/geoImportDisplay.js
+++ b/src/data/geoImportDisplay.js
@@ -1,9 +1,7 @@
// One imported file (UTILITIES ▸ Import) drawn on the globe contract, the
// same on every engine, from its plain records (src/data/geoImportRecords.js),
// with Cesium's look as the reference:
-// - polygons: one ground 'polygon' set of their fills; a raised polygon
-// (KML absolute or relative) also gets its outline as lines at its
-// heights, as Cesium drew it (its fill drapes: the ground has no heights);
+// - polygons: ground fills, or meshes and outlines at the vertices' heights;
// - ground lines: one ground 'corridor' set in pixels, over the fills;
// - lines in the air: one engine line each;
// - points: one sprite set per look, each sprite a square centred on its
@@ -11,13 +9,16 @@
// geoImportPins.js); points on the ground draw over the scene and are
// hidden beyond the horizon instead, as Cesium's clamped billboards
// ignored the depth test;
-// - ground overlays: one image imagery layer each over its rectangle.
+// - ground overlays: one image imagery layer each over its rectangle, its
+// image tinted and turned on a canvas when the KML says so, an absolute
+// one raised to its altitude; a plain colour is a ground fill.
// Nothing is pickable: an imported file has no card. Ground heights under
// the points come from engine.surface, sampled again by refreshGround():
// the terrain where the globe is drawn, else (3D tiles) the scene drawn
// under a low camera, never an imported point (as Cesium's import kept its
// pins out of height queries).
+import earcut from 'earcut';
import * as geo from '../geo/index.js';
import { normalizeGroundItem } from '../globe/ground.js';
import { composeIcon } from './geoImportPins.js';
@@ -34,6 +35,10 @@ const HORIZON_LIFT_M = 2000;
// drawn now; higher up the terrain answers (Cesium's 250 km).
const CLAMP_TILES_BELOW_M = 250_000;
const BATCH = 256;
+// Canvas pixels every imported icon look shares (64 MiB of RGBA).
+const ICON_PIXEL_BUDGET = 16 * 1024 * 1024;
+// The longest side of a tinted or turned ground overlay's canvas.
+const MAX_OVERLAY_SIDE = 4096;
let serial = 0;
// Each engine's imported point sets, left out of each other's ground.
const pointSets = new WeakMap();
@@ -60,6 +65,103 @@ function checked(kind, item) {
}
}
+// A ground overlay's box with east past west across 180°, and how a corner
+// turns by its rotation: KML's degrees counter-clockwise about the centre,
+// in longitude and latitude (Cesium's rectangle rotation).
+function overlayBox({ rectangle, rotation = 0 }) {
+ const { west, south, north } = rectangle;
+ const east = rectangle.east < west ? rectangle.east + 360 : rectangle.east;
+ const box = { west, south, east, north };
+ const center = [(west + east) / 2, (south + north) / 2];
+ const angle = (rotation * Math.PI) / 180;
+ const [cos, sin] = [Math.cos(angle), Math.sin(angle)];
+ const turn = ([lon, lat]) => {
+ const [x, y] = [lon - center[0], lat - center[1]];
+ return [center[0] + x * cos - y * sin, center[1] + x * sin + y * cos];
+ };
+ return { box, center, angle, turn };
+}
+
+// What a ground overlay's image layer draws: its image tinted by the
+// overlay's colour and turned by its rotation onto the box around the
+// turned image (transparent outside it). Null when that box passes a pole.
+async function overlayImage(overlay, loadImage, createCanvas) {
+ const tint = overlay.color.slice(0, 3);
+ const { box, center, angle } = overlayBox(overlay);
+ if (tint.every((value) => value === 1) && !angle)
+ return { url: overlay.url, rectangle: overlay.rectangle };
+ const [width, height] = [box.east - box.west, box.north - box.south];
+ const [cos, sin] = [Math.abs(Math.cos(angle)), Math.abs(Math.sin(angle))];
+ const [spanX, spanY] = [
+ width * cos + height * sin,
+ width * sin + height * cos,
+ ];
+ const rectangle = {
+ west: center[0] - spanX / 2,
+ south: center[1] - spanY / 2,
+ east: center[0] + spanX / 2,
+ north: center[1] + spanY / 2,
+ };
+ if (rectangle.south < -90 || rectangle.north > 90 || spanX >= 360)
+ return null;
+ let image;
+ try {
+ image = await loadImage(overlay.url);
+ } catch {
+ // An image that does not decode draws nothing, as on the plain path.
+ return { url: overlay.url, rectangle: overlay.rectangle };
+ }
+ const natural = [
+ image.naturalWidth || image.width,
+ image.naturalHeight || image.height,
+ ];
+ // Pixels per degree: one scale on both axes when turned, so the image
+ // turns in degrees as Cesium's does.
+ let [scaleX, scaleY] = [natural[0] / width, natural[1] / height];
+ if (angle) scaleX = scaleY = Math.max(scaleX, scaleY);
+ const fit = Math.min(
+ 1,
+ MAX_OVERLAY_SIDE / (spanX * scaleX),
+ MAX_OVERLAY_SIDE / (spanY * scaleY),
+ );
+ [scaleX, scaleY] = [scaleX * fit, scaleY * fit];
+ const canvas = createCanvas();
+ canvas.width = Math.max(1, Math.round(spanX * scaleX));
+ canvas.height = Math.max(1, Math.round(spanY * scaleY));
+ const context = canvas.getContext('2d');
+ context.translate(canvas.width / 2, canvas.height / 2);
+ // The canvas's y runs south: a counter-clockwise turn is a negative one.
+ context.rotate(-angle);
+ context.drawImage(
+ image,
+ (-width * scaleX) / 2,
+ (-height * scaleY) / 2,
+ width * scaleX,
+ height * scaleY,
+ );
+ if (tint.some((value) => value !== 1)) {
+ const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
+ for (let i = 0; i < pixels.data.length; i += 4)
+ for (let channel = 0; channel < 3; channel++)
+ pixels.data[i + channel] *= tint[channel];
+ context.putImageData(pixels, 0, 0);
+ }
+ const wrap = (lon) => (lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon);
+ return {
+ image: canvas,
+ rectangle: {
+ ...rectangle,
+ west: wrap(rectangle.west),
+ east: wrap(rectangle.east),
+ },
+ };
+}
+
+// KML headings are from north (Cesium's billboard on the Earth's axis):
+// the sprite turns on screen with the camera's heading.
+const turnsWithCamera = (look) => look.pins.some((pin) => pin.rotation);
+const onScreen = (pin, heading) => (pin.rotation ? pin.rotation + heading : 0);
+
const flat = (positions) => positions.map(([lon, lat]) => [lon, lat]);
const cartesian = ([lon, lat, height = 0]) =>
geo.cartesian3.fromDegrees(lon, lat, height);
@@ -110,6 +212,9 @@ export async function drawImportedFile(
let removeCull = null;
const groundSets = [];
const lines = [];
+ const meshes = [];
+ const pins = [];
+ const kinds = { ...records.undrawn };
const layers = [];
const looks = new Map();
let drawn = 0;
@@ -120,12 +225,14 @@ export async function drawImportedFile(
removeCull = null;
for (const set of groundSets.splice(0)) set.destroy();
for (const line of lines.splice(0)) line.destroy();
+ for (const mesh of meshes.splice(0)) mesh.destroy();
for (const layer of layers.splice(0)) layer.destroy();
for (const look of looks.values()) {
pointSetsOf(engine).delete(look.set);
look.set?.destroy();
}
looks.clear();
+ pins.length = 0;
}
try {
@@ -133,7 +240,34 @@ export async function drawImportedFile(
const fills = [];
for (const polygon of records.polygons) {
let any = false;
- if (polygon.fill) {
+ if (polygon.fill && polygon.raised) {
+ const vertices = polygon.rings.flat();
+ const origin = vertices[0]?.[0] ?? 0;
+ const xy = vertices.flatMap(([lon, lat]) => [
+ origin + ((((lon - origin + 180) % 360) + 360) % 360) - 180,
+ lat,
+ ]);
+ let count = 0;
+ const holes = polygon.rings
+ .slice(0, -1)
+ .map((ring) => (count += ring.length));
+ const indices = earcut(xy, holes);
+ if (indices.length) {
+ meshes.push(
+ engine.meshes.createMesh({
+ id: `${prefix}-fill-${meshes.length}`,
+ positions: vertices.flatMap((vertex) => {
+ const { x, y, z } = cartesian(vertex);
+ return [x, y, z];
+ }),
+ indices,
+ color: polygon.fill,
+ show: shown,
+ }),
+ );
+ any = true;
+ }
+ } else if (polygon.fill) {
const item = checked('polygon', {
rings: polygon.rings.map(flat),
fill: polygon.fill,
@@ -160,34 +294,47 @@ export async function drawImportedFile(
if (any) drawn++;
}
for (const overlay of records.overlays) {
+ // Cesium raises only an absolute overlay, to its altitude.
+ const raise =
+ overlay.altitudeMode === 'absolute' && overlay.altitude > 0
+ ? overlay.altitude
+ : undefined;
if (overlay.url) {
+ const prepared = await overlayImage(overlay, loadImage, createCanvas);
+ if (!prepared) {
+ kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1;
+ continue;
+ }
layers.push(
engine.imagery.createLayer({
id: `${prefix}-overlay-${layers.length}`,
- source: {
- kind: 'image',
- url: overlay.url,
- rectangle: overlay.rectangle,
- },
+ source: { kind: 'image', ...prepared },
// Cesium's transparent image material: at most 0.99.
alpha: Math.min(0.99, overlay.color[3]),
show: shown,
order: OVERLAY_ORDER,
+ ...(raise ? { raise } : {}),
}),
);
drawn++;
continue;
}
+ // A plain colour draws as a ground fill.
+ if (raise) {
+ kinds.raisedOverlay = (kinds.raisedOverlay ?? 0) + 1;
+ continue;
+ }
const { west, south, east, north } = overlay.rectangle;
const item = checked('cell', { west, south, east, north });
if (!item) continue;
+ const { box, turn } = overlayBox(overlay);
const rings = [
[
- [west, south],
- [east < west ? east + 360 : east, south],
- [east < west ? east + 360 : east, north],
- [west, north],
- ],
+ [box.west, box.south],
+ [box.east, box.south],
+ [box.east, box.north],
+ [box.west, box.north],
+ ].map(turn),
];
fills.push({ rings, fill: overlay.color });
drawn++;
@@ -261,6 +408,11 @@ export async function drawImportedFile(
rotation: point.icon.rotation ?? 0,
});
}
+ pins.push(...[...looks.values()].flatMap((look) => look.pins));
+ const maxSide = Math.min(
+ 512,
+ Math.floor(Math.sqrt(ICON_PIXEL_BUDGET / Math.max(1, looks.size))),
+ );
await Promise.all(
[...looks.values()].map(async (look) => {
const url = look.icon.image?.url;
@@ -279,6 +431,7 @@ export async function drawImportedFile(
image,
createCanvas,
ratio: pixelRatio,
+ maxSide,
});
look.set = engine.movers.createSet({
id: `${prefix}-pins-${look.depth}-${looks.size}-${++serial}`,
@@ -299,7 +452,11 @@ export async function drawImportedFile(
}),
);
if (destroyed) throw new Error('The imported file was removed');
- settle(heightContext());
+ for (let from = 0; from < pins.length; from += BATCH) {
+ settle(heightContext(), from, from + BATCH);
+ if (from + BATCH < pins.length)
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
for (const look of looks.values()) writePins(look);
syncCull();
} catch (error) {
@@ -316,11 +473,30 @@ export async function drawImportedFile(
(engine.imagery?.surface?.() ?? 'globe') !== 'globe' &&
surface.clampToHeightSupported &&
engine.camera.height() < CLAMP_TILES_BELOW_M;
- return { surface, tiles, exclude: [...pointSetsOf(engine)] };
+ return {
+ surface,
+ tiles,
+ exclude: [...pointSetsOf(engine)],
+ size: tiles ? engine.host.size() : null,
+ };
+ }
+
+ // Whether a position is in the window now: clamping to 3D tiles reads
+ // the tiles drawn, and a point elsewhere would only cost the query.
+ function inWindow(position, { width, height }) {
+ if (!engine.projector.horizonVisible(position)) return false;
+ const screen = engine.projector.worldToWindow(position);
+ return (
+ Boolean(screen) &&
+ screen.x >= 0 &&
+ screen.y >= 0 &&
+ screen.x <= width &&
+ screen.y <= height
+ );
}
// The ground under a pin, falling back to the last known one.
- function groundUnder(pin, { surface, tiles, exclude }) {
+ function groundUnder(pin, { surface, tiles, exclude, size }) {
const at = geo.cartographic.fromDegrees(pin.lon, pin.lat, 0);
const terrain = surface.terrainHeightSupported
? surface.terrainHeight(at)
@@ -331,7 +507,10 @@ export async function drawImportedFile(
? Math.max(0, terrain)
: terrain
: (pin.ground ?? 0);
- if (tiles) {
+ if (
+ tiles &&
+ inWindow(geo.cartesian3.fromDegrees(pin.lon, pin.lat, ground), size)
+ ) {
const top = surface.clampToHeight(
geo.cartesian3.fromDegrees(pin.lon, pin.lat, Math.max(0, ground)),
exclude,
@@ -342,33 +521,33 @@ export async function drawImportedFile(
return ground;
}
- function settle(context, from = 0, to = Infinity) {
- let at = 0;
- for (const look of looks.values())
- for (const pin of look.pins) {
- if (at++ < from || at > to) continue;
- if (pin.height === null) pin.ground = groundUnder(pin, context);
- const height = pin.height ?? pin.ground + pin.offset;
- pin.position = geo.cartesian3.fromDegrees(pin.lon, pin.lat, height);
- pin.horizon = geo.cartesian3.fromDegrees(
- pin.lon,
- pin.lat,
- height + HORIZON_LIFT_M,
- );
- }
+ function settle(context, from = 0, to = pins.length) {
+ for (let i = from; i < Math.min(to, pins.length); i++) {
+ const pin = pins[i];
+ if (pin.height === null) pin.ground = groundUnder(pin, context);
+ const height = pin.height ?? pin.ground + pin.offset;
+ pin.position = geo.cartesian3.fromDegrees(pin.lon, pin.lat, height);
+ pin.horizon = geo.cartesian3.fromDegrees(
+ pin.lon,
+ pin.lat,
+ height + HORIZON_LIFT_M,
+ );
+ }
}
function writePins(look) {
const { set, pins } = look;
if (!set) return;
const culled = look.depth === 'always';
+ const heading = turnsWithCamera(look) ? engine.camera.pose().heading : 0;
pins.forEach((pin, index) => {
if (culled) pin.show = engine.projector.horizonVisible(pin.horizon);
+ pin.drawnRotation = onScreen(pin, heading);
set.set(index, {
position: pin.position,
cell: 'icon',
color: pin.color,
- rotation: pin.rotation,
+ rotation: pin.drawnRotation,
show: pin.show,
});
});
@@ -377,14 +556,18 @@ export async function drawImportedFile(
set.commit();
}
- // Hides the ground points beyond the horizon; true when any show changed.
- function cull(look) {
+ // Hides the ground points beyond the horizon and turns the headed ones
+ // with the camera; true when any changed.
+ function cull(look, heading) {
let changed = false;
look.pins.forEach((pin, index) => {
- const show = engine.projector.horizonVisible(pin.horizon);
- if (show === pin.show) return;
+ const show =
+ look.depth !== 'always' || engine.projector.horizonVisible(pin.horizon);
+ const rotation = onScreen(pin, heading);
+ if (show === pin.show && rotation === pin.drawnRotation) return;
pin.show = show;
- look.set.set(index, { show });
+ pin.drawnRotation = rotation;
+ look.set.set(index, { show, rotation });
changed = true;
});
if (changed) look.set.commit();
@@ -392,33 +575,39 @@ export async function drawImportedFile(
}
function syncCull() {
const culled = [...looks.values()].filter(
- (look) => look.set && look.depth === 'always',
+ (look) => look.set && (look.depth === 'always' || turnsWithCamera(look)),
);
if (!culled.length || !shown) {
removeCull?.();
removeCull = null;
return;
}
+ const turning = culled.some(turnsWithCamera);
const lastCamera = geo.cartesian3.create(Number.NaN, 0, 0);
+ let lastHeading = Number.NaN;
removeCull ??= engine.frames.preRender.addEventListener(() => {
const live = engine.camera.livePosition;
- if (geo.cartesian3.equals(live, lastCamera)) return;
+ const heading = turning ? engine.camera.pose().heading : 0;
+ if (geo.cartesian3.equals(live, lastCamera) && heading === lastHeading)
+ return;
+ lastHeading = heading;
geo.cartesian3.clone(live, lastCamera);
let changed = false;
- for (const look of culled) changed = cull(look) || changed;
+ for (const look of culled) changed = cull(look, heading) || changed;
if (changed) request();
});
}
- const undrawn = Object.values(records.undrawn).reduce((n, c) => n + c, 0);
+ const undrawn = Object.values(kinds).reduce((n, c) => n + c, 0);
return {
- report: { drawn, undrawn, kinds: { ...records.undrawn } },
+ report: { drawn, undrawn, kinds },
setShow(visibleNow) {
visibleNow = Boolean(visibleNow);
if (destroyed || visibleNow === shown) return;
shown = visibleNow;
for (const set of groundSets) set.show(shown);
for (const line of lines) line.show(shown);
+ for (const mesh of meshes) mesh.show(shown);
for (const layer of layers) layer.show(shown);
for (const look of looks.values()) {
look.set?.setCount(shown ? look.count : 0);
@@ -444,7 +633,7 @@ export async function drawImportedFile(
},
/** Sample the ground under the points again (a map or the terrain changed). */
async refreshGround(signal) {
- const total = [...looks.values()].reduce((n, l) => n + l.pins.length, 0);
+ const total = pins.length;
for (let from = 0; from < total; from += BATCH) {
signal?.throwIfAborted();
if (destroyed) return;
diff --git a/src/data/geoImportDisplay.test.mjs b/src/data/geoImportDisplay.test.mjs
index c6ae3cc5d..85c5a795a 100644
--- a/src/data/geoImportDisplay.test.mjs
+++ b/src/data/geoImportDisplay.test.mjs
@@ -4,7 +4,7 @@ import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs';
import { drawImportedFile } from './geoImportDisplay.js';
-import { geoJsonRecords } from './geoImportRecords.js';
+import { emptyRecords, geoJsonRecords } from './geoImportRecords.js';
// A 2D context that only records what draws.
const createCanvas = () => ({
@@ -191,6 +191,8 @@ for (const make of GROUND_HARNESSES) {
viewer.scene.clampToHeightSupported = true;
} else engine.imagery.surface = () => 'tiles';
engine.camera.height = () => 1000;
+ engine.projector.worldToWindow = () => ({ x: 1, y: 1 });
+ engine.projector.horizonVisible = () => true;
const excluded = [];
engine.surface.clampToHeight = (position, exclude) => {
excluded.push(exclude);
@@ -270,8 +272,9 @@ for (const make of GROUND_HARNESSES) {
visible: false,
});
const fill = set(engine.ground.list()[0].id);
- assert.equal(fill.items.length, 2);
- assert.equal(fill.items[0].rings.length, 2);
+ assert.equal(fill.items.length, 1);
+ assert.equal(engine.meshes.list().length, 1);
+ assert.equal(fill.items[0].rings.length, 1);
assert.ok(fill.items[0].rings.flat().every((p) => p.length === 2));
assert.equal(fill.shown, false);
assert.equal(engine.lines.list().length, 2);
@@ -339,6 +342,222 @@ for (const make of GROUND_HARNESSES) {
},
);
+ displayTest(
+ 'KML headings follow north as the camera rotates',
+ async ({ engine, pins }) => {
+ let heading = 0;
+ engine.camera.pose = () => ({ heading });
+ const records = geoJsonRecords(
+ { type: 'Point', coordinates: [1, 2] },
+ '#39d0ff',
+ );
+ records.points[0].icon.rotation = -Math.PI / 4;
+ const drawing = await drawImportedFile(engine, records, { createCanvas });
+ assert.equal(pins[0].items[0].rotation, -Math.PI / 4);
+ heading = Math.PI / 2;
+ engine.frames.preRender.raiseEvent();
+ assert.equal(pins[0].items[0].rotation, Math.PI / 4);
+ drawing.destroy();
+ },
+ );
+
+ // A canvas whose context records the transform and holds one pixel.
+ const recordingCanvas = () => {
+ const calls = [];
+ const pixels = { data: new Uint8ClampedArray([128, 64, 32, 128]) };
+ const context = {
+ translate: (...args) => calls.push(['translate', ...args]),
+ rotate: (angle) => calls.push(['rotate', angle]),
+ drawImage: (_image, ...args) => calls.push(['drawImage', ...args]),
+ getImageData: () => pixels,
+ putImageData: (value) => calls.push(['putImageData', value]),
+ };
+ return { calls, pixels, canvas: { getContext: () => context } };
+ };
+ const overlayLayer = (engine) => {
+ const layers = [];
+ const create = engine.imagery.createLayer;
+ engine.imagery.createLayer = (options) => {
+ layers.push(options);
+ return create(options);
+ };
+ return layers;
+ };
+
+ displayTest(
+ 'colored overlays multiply image RGB and retain layer alpha',
+ async ({ engine }) => {
+ const { calls, pixels, canvas } = recordingCanvas();
+ const layers = overlayLayer(engine);
+ const records = emptyRecords();
+ records.overlays.push({
+ url: 'blob:overlay',
+ color: [1, 0, 0, 0.5],
+ rectangle: { west: 0, south: 0, east: 1, north: 1 },
+ rotation: 0,
+ });
+ const drawing = await drawImportedFile(engine, records, {
+ createCanvas: () => canvas,
+ loadImage: async () => ({ width: 32, height: 16 }),
+ });
+ assert.equal(layers[0].source.image, canvas);
+ assert.deepEqual(
+ { ...layers[0].source.rectangle },
+ { west: 0, south: 0, east: 1, north: 1 },
+ );
+ assert.equal(layers[0].alpha, 0.5);
+ assert.deepEqual([canvas.width, canvas.height], [32, 16]);
+ assert.ok(
+ calls.some(
+ ([name, value]) => name === 'putImageData' && value === pixels,
+ ),
+ );
+ assert.deepEqual([...pixels.data], [128, 0, 0, 128]);
+ drawing.destroy();
+ },
+ );
+
+ displayTest(
+ 'a rotated overlay turns its image counter-clockwise onto the box around it',
+ async ({ engine }) => {
+ const { calls, canvas } = recordingCanvas();
+ const layers = overlayLayer(engine);
+ const records = emptyRecords();
+ records.overlays.push({
+ url: 'blob:overlay',
+ color: [1, 1, 1, 1],
+ // Two degrees by one, across 180°, turned a quarter.
+ rectangle: { west: 179, south: 10, east: -179, north: 11 },
+ rotation: 90,
+ });
+ const drawing = await drawImportedFile(engine, records, {
+ createCanvas: () => canvas,
+ loadImage: async () => ({ width: 200, height: 100 }),
+ });
+ assert.equal(drawing.report.drawn, 1);
+ assert.equal(drawing.report.undrawn, 0);
+ const { rectangle } = layers[0].source;
+ assert.ok(Math.abs(rectangle.west - 179.5) < 1e-9);
+ assert.ok(Math.abs(rectangle.east + 179.5) < 1e-9);
+ assert.ok(Math.abs(rectangle.south - 9.5) < 1e-9);
+ assert.ok(Math.abs(rectangle.north - 11.5) < 1e-9);
+ // 100 pixels a degree on both axes: the turned box is 1° by 2°.
+ assert.deepEqual([canvas.width, canvas.height], [100, 200]);
+ assert.deepEqual(
+ calls.find(([name]) => name === 'rotate'),
+ ['rotate', -Math.PI / 2],
+ );
+ assert.deepEqual(
+ calls.find(([name]) => name === 'drawImage'),
+ ['drawImage', -100, -50, 200, 100],
+ );
+ assert.equal(
+ calls.some(([name]) => name === 'putImageData'),
+ false,
+ );
+ drawing.destroy();
+ },
+ );
+
+ displayTest(
+ 'a rotated overlay whose box passes a pole is reported undrawn',
+ async ({ engine }) => {
+ const records = emptyRecords();
+ records.overlays.push({
+ url: 'blob:overlay',
+ color: [1, 1, 1, 1],
+ rectangle: { west: 0, south: 80, east: 30, north: 89 },
+ rotation: 45,
+ });
+ const drawing = await drawImportedFile(engine, records, { createCanvas });
+ assert.deepEqual(drawing.report, {
+ drawn: 0,
+ undrawn: 1,
+ kinds: { rotatedOverlay: 1 },
+ });
+ assert.equal(engine.imagery.list().length, 0);
+ drawing.destroy();
+ },
+ );
+
+ displayTest(
+ 'a rotated plain-colour overlay fills its turned corners',
+ async ({ engine, set }) => {
+ const records = emptyRecords();
+ records.overlays.push({
+ url: null,
+ color: [0, 1, 0, 0.5],
+ rectangle: { west: 0, south: 0, east: 2, north: 2 },
+ rotation: 90,
+ });
+ const drawing = await drawImportedFile(engine, records, { createCanvas });
+ const fill = set(engine.ground.list()[0].id);
+ const [ring] = fill.items[0].rings;
+ const near = (a, b) => a.every((v, i) => Math.abs(v - b[i]) < 1e-9);
+ assert.ok(near(ring[0], [2, 0]), JSON.stringify(ring));
+ assert.ok(near(ring[1], [2, 2]), JSON.stringify(ring));
+ assert.ok(near(ring[2], [0, 2]), JSON.stringify(ring));
+ drawing.destroy();
+ },
+ );
+
+ displayTest(
+ 'an absolute overlay is raised to its altitude, a relative one drapes',
+ async ({ engine }) => {
+ const layers = overlayLayer(engine);
+ const records = emptyRecords();
+ for (const altitudeMode of ['absolute', 'relative'])
+ records.overlays.push({
+ url: 'blob:overlay',
+ altitudeMode,
+ altitude: 1000,
+ color: [1, 1, 1, 1],
+ rectangle: { west: 0, south: 0, east: 1, north: 1 },
+ rotation: 0,
+ });
+ const drawing = await drawImportedFile(engine, records, { createCanvas });
+ assert.deepEqual(drawing.report, { drawn: 2, undrawn: 0, kinds: {} });
+ assert.equal(layers[0].raise, 1000);
+ assert.equal(layers[1].raise, undefined);
+ drawing.destroy();
+ },
+ );
+
+ displayTest(
+ 'tile clamps only sample visible pins and yield between batches',
+ async ({ engine }) => {
+ engine.imagery.surface = () => 'tiles';
+ engine.camera.height = () => 1000;
+ engine.projector.horizonVisible = () => true;
+ let visible = false;
+ engine.projector.worldToWindow = () => (visible ? { x: 1, y: 1 } : null);
+ let clamps = 0;
+ engine.surface.clampToHeight = (position) => {
+ clamps++;
+ return position;
+ };
+ const records = geoJsonRecords(
+ {
+ type: 'MultiPoint',
+ coordinates: Array.from({ length: 600 }, () => [1, 2]),
+ },
+ '#39d0ff',
+ );
+ const drawing = await drawImportedFile(engine, records, { createCanvas });
+ assert.equal(clamps, 0);
+ visible = true;
+ let beforeYield;
+ const heartbeat = setTimeout(() => {
+ beforeYield = clamps;
+ }, 0);
+ await drawing.refreshGround();
+ clearTimeout(heartbeat);
+ assert.equal(beforeYield, 256);
+ assert.equal(clamps, 600);
+ drawing.destroy();
+ },
+ );
+
displayTest(
'an aborted ground refresh leaves the existing drawing owned',
async ({ engine }) => {
diff --git a/src/data/geoImportIconBudget.test.mjs b/src/data/geoImportIconBudget.test.mjs
new file mode 100644
index 000000000..53e0a9f55
--- /dev/null
+++ b/src/data/geoImportIconBudget.test.mjs
@@ -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`);
+});
diff --git a/src/data/geoImportKml.js b/src/data/geoImportKml.js
index b82768975..7fc9f8a12 100644
--- a/src/data/geoImportKml.js
+++ b/src/data/geoImportKml.js
@@ -380,6 +380,8 @@ function readGroundOverlay(el, records) {
rectangle,
color,
rotation: numberOf(box, 'rotation') ?? 0,
+ altitude: numberOf(el, 'altitude') ?? 0,
+ altitudeMode: altitudeOf(el),
});
}
diff --git a/src/data/geoImportKml.test.mjs b/src/data/geoImportKml.test.mjs
index c97aed21a..fe93d67ca 100644
--- a/src/data/geoImportKml.test.mjs
+++ b/src/data/geoImportKml.test.mjs
@@ -400,3 +400,15 @@ test('a KML file loads to records with its name, count and outside content remov
assert.equal(loaded.records.points[0].name, 'QA KML pin');
loaded.revoke();
});
+
+test('GroundOverlay retains altitude and altitude mode for drawing diagnostics', () => {
+ const records = readKmlRecords(
+ parseXml(
+ kml(
+ `1000absolute0101`,
+ ),
+ ),
+ );
+ assert.equal(records.overlays[0].altitude, 1000);
+ assert.equal(records.overlays[0].altitudeMode, 'absolute');
+});
diff --git a/src/data/geoImportPins.js b/src/data/geoImportPins.js
index 7866fc0fc..dcefc005c 100644
--- a/src/data/geoImportPins.js
+++ b/src/data/geoImportPins.js
@@ -87,7 +87,10 @@ export function iconBox(icon, natural) {
* `ratio` canvas pixels per CSS pixel. Returns the canvas and the sprite's
* side in CSS pixels.
*/
-export function composeIcon(icon, { image, createCanvas, ratio = 1 } = {}) {
+export function composeIcon(
+ icon,
+ { image, createCanvas, ratio = 1, maxSide = 512 } = {},
+) {
const natural = image
? {
width: image.naturalWidth || image.width,
@@ -96,7 +99,7 @@ export function composeIcon(icon, { image, createCanvas, ratio = 1 } = {}) {
: undefined;
const box = iconBox(icon, natural);
// At least one canvas pixel per icon pixel, at most 512 a side.
- const k = Math.min(ratio, 512 / box.side);
+ const k = Math.min(ratio, maxSide / box.side);
const canvas = createCanvas();
canvas.width = Math.max(1, Math.ceil(box.side * k));
canvas.height = canvas.width;
diff --git a/src/data/geoImportRaisedPolygon.test.mjs b/src/data/geoImportRaisedPolygon.test.mjs
new file mode 100644
index 000000000..127cd051b
--- /dev/null
+++ b/src/data/geoImportRaisedPolygon.test.mjs
@@ -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);
+});
diff --git a/src/data/geoImportRecords.js b/src/data/geoImportRecords.js
index e809fcfa3..9d1853c62 100644
--- a/src/data/geoImportRecords.js
+++ b/src/data/geoImportRecords.js
@@ -16,7 +16,9 @@
// @property {ImportedPolygon[]} polygons
// @property {ImportedOverlay[]} overlays
// @property {Object} undrawn What the file has that no
-// engine draws, by kind (`wall`, `extruded`, `latLonQuad`), counted.
+// engine draws, by kind (`wall`, `extruded`, `latLonQuad`), counted;
+// the display adds the overlays it cannot draw (`rotatedOverlay`,
+// `raisedOverlay`).
//
// @typedef {object} ImportedPoint
// @property {number[]} position
@@ -62,6 +64,9 @@
// @property {{ west: number, south: number, east: number, north: number }} rectangle
// @property {number[]} color Multiplies the image; its alpha fades it.
// @property {number} rotation Degrees counter-clockwise (KML LatLonBox).
+// @property {number} altitude Metres (KML altitude); Cesium raises an
+// absolute overlay to it.
+// @property {'ground'|'relative'|'absolute'} altitudeMode
/** Longest plain-text name kept from a file's features. */
const MAX_IMPORT_NAME_LENGTH = 120;
@@ -162,7 +167,7 @@ export function geoJsonRecords(geojson, color) {
origin: 'bottom',
},
});
- else if (kind === 'LineString')
+ else if (kind === 'LineString' && part.length >= 2)
records.lines.push({
name,
positions: part.map(position),
@@ -170,7 +175,10 @@ export function geoJsonRecords(geojson, color) {
color: stroke,
widthPx: GEOJSON_STROKE_PX,
});
- else if (kind === 'Polygon' && part[0]?.length)
+ else if (
+ kind === 'Polygon' &&
+ new Set(part[0]?.map(([lon, lat]) => `${lon},${lat}`)).size >= 3
+ )
records.polygons.push({
name,
rings: part.map((ring) => ring.map(position)),
diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js
index 9081b093f..31f9183b7 100644
--- a/src/data/layerStateCoordinator.js
+++ b/src/data/layerStateCoordinator.js
@@ -404,25 +404,6 @@ export class LayerStateCoordinator {
live,
enabled.has(entry.id),
);
- // A same-document engine choice retains operational layer intents.
- // The share still owns durable preferences and later history visits.
- if (
- live &&
- this.dataManager.layers.get(entry.id) === live.entry &&
- !live.entry.lifecycleUncertain &&
- live.entry.enabled === targetEnabled
- ) {
- return {
- layerId: entry.id,
- targetEnabled,
- origin,
- phase: 'retained',
- ...currentLayerOutcome(this.dataManager, entry.id),
- appliedOptions: {},
- persistenceWrite: false,
- succeeded: true,
- };
- }
const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
for (const trackingKey of Object.values(
@@ -445,6 +426,37 @@ export class LayerStateCoordinator {
succeeded: false,
};
}
+ // A same-document engine choice retains operational layer intents.
+ // The share still owns durable preferences and later history visits:
+ // on a visit, the entry's options that differ from the live layer
+ // apply; a pick keeps the live layer as it is.
+ if (
+ live &&
+ this.dataManager.layers.get(entry.id) === live.entry &&
+ !live.entry.lifecycleUncertain &&
+ live.entry.enabled === targetEnabled
+ ) {
+ const current = this.dataManager.getLayerParams(entry.id);
+ const changed =
+ live.visit === true &&
+ options &&
+ Object.entries(options).some(
+ ([key, value]) => !Object.is(current?.[key], value),
+ );
+ const succeeded =
+ !changed ||
+ this.dataManager.setLayerParams(entry.id, options, { origin });
+ return {
+ layerId: entry.id,
+ targetEnabled,
+ origin,
+ phase: 'retained',
+ ...currentLayerOutcome(this.dataManager, entry.id),
+ appliedOptions: changed && succeeded ? options : {},
+ persistenceWrite: false,
+ succeeded,
+ };
+ }
// Reserve passive option state before any asynchronous lifecycle work.
// A later explicit params intent then wins on its own lane without
// cancelling or being overwritten by the visibility restore.
diff --git a/src/data/layerStateHandoffOptions.test.mjs b/src/data/layerStateHandoffOptions.test.mjs
new file mode 100644
index 000000000..c995d9c9d
--- /dev/null
+++ b/src/data/layerStateHandoffOptions.test.mjs
@@ -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, {});
+});
diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js
index 3ccb67950..f9d925e8e 100644
--- a/src/data/lifecycle.js
+++ b/src/data/lifecycle.js
@@ -1207,9 +1207,8 @@ export class LayerLifecycle extends LayerSceneBinding {
const entry = this.layers.get(layerId);
if (!entry) return { intentEpoch: null, promise: Promise.resolve() };
const desiredState = Boolean(shouldEnable);
- // A layer leaving ends the camera arrival correction on a globe with a
- // viewer (Cesium).
- if (!desiredState && this.viewer) cancelCameraArrival(this.engine);
+ // A layer leaving ends its camera arrival correction.
+ if (!desiredState) cancelCameraArrival(this.engine);
if (entry.destroying) {
return {
intentEpoch: null,
diff --git a/src/data/trackedAircraftScenes.test.mjs b/src/data/trackedAircraftScenes.test.mjs
index fe71c2096..938083088 100644
--- a/src/data/trackedAircraftScenes.test.mjs
+++ b/src/data/trackedAircraftScenes.test.mjs
@@ -169,10 +169,17 @@ for (const makeHarness of [fakeHarness, createCesiumMoversEngine]) {
height(native.gevDisplayPosition()) < 100,
'a ready tracked model releases the billboard floor',
);
+ // While the model owns the visual the card sits on it, not on the
+ // released billboard floor below it.
+ const card = { ...publications.at(-1).position() };
assert.deepEqual(
- publications.at(-1).position(),
- follow.label.position(),
- 'the card uses the follow label cache',
+ card,
+ { ...native.gevVisualPosition() },
+ 'the card anchors on the tracked model',
+ );
+ assert.ok(
+ height(card) >= 204.5 && height(card) < 217,
+ 'the card stands with the model on the sampled ground',
);
const cached = { ...native.gevDisplayPosition() };
publications.at(-1).position();
@@ -185,6 +192,11 @@ for (const makeHarness of [fakeHarness, createCesiumMoversEngine]) {
await frames();
const zoomedOut = layer.qaDrawn().find((row) => row.trackedModel)?.model;
assert.ok(!zoomedOut?.show, 'zooming out releases the tracked model');
+ assert.deepEqual(
+ { ...publications.at(-1).position() },
+ { ...follow.label.position() },
+ 'without a model the card uses the follow label cache',
+ );
if (layerId === 'flights')
assert.ok(
height(native.gevDisplayPosition()) >= 205.5,
diff --git a/src/data/trackedReadout.js b/src/data/trackedReadout.js
index 629d8f56f..7ffad746b 100644
--- a/src/data/trackedReadout.js
+++ b/src/data/trackedReadout.js
@@ -191,6 +191,7 @@ export function createTrackedReadout(
return typeof model === 'function' ? model() : model;
},
gevDisplayPosition: () => (handle.label ?? label).position(),
+ gevVisualPosition: () => handle.native?.gevVisualPosition?.(),
};
_labelSubjects.set(handle, subject);
}
diff --git a/src/data/trackedReadout.visualAnchor.test.mjs b/src/data/trackedReadout.visualAnchor.test.mjs
new file mode 100644
index 000000000..138cf9117
--- /dev/null
+++ b/src/data/trackedReadout.visualAnchor.test.mjs
@@ -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);
+});
diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js
index 89579ac2b..6707976ef 100644
--- a/src/data/vesselLabels.js
+++ b/src/data/vesselLabels.js
@@ -51,30 +51,6 @@ export const VESSEL_TYPE_CSS_COLORS = Object.freeze([
...new Set([...TYPE_STYLES, DEFAULT_STYLE].map(({ css }) => css)),
]);
-const NUMERIC_TYPE_SPECIALS = {
- 30: 'FISHING',
- 31: 'TOWING',
- 32: 'TOWING',
- 33: 'DREDGER',
- 34: 'DIVE OPS',
- 35: 'MILITARY',
- 36: 'SAILING',
- 37: 'PLEASURE',
- 50: 'PILOT',
- 51: 'SAR',
- 52: 'TUG',
- 53: 'PORT TENDER',
- 54: 'ANTI-POLLUTION',
- 55: 'LAW ENFORCE',
- 58: 'MEDICAL',
-};
-const NUMERIC_TYPE_FAMILIES = {
- 4: 'HIGH-SPEED',
- 6: 'PASSENGER',
- 7: 'CARGO',
- 8: 'TANKER',
- 9: 'OTHER',
-};
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
// and tests match on them.
const TYPE_DISPLAY_KEYS = Object.freeze({
diff --git a/src/geo/matrix4.js b/src/geo/matrix4.js
index 676b35ce6..3e312c659 100644
--- a/src/geo/matrix4.js
+++ b/src/geo/matrix4.js
@@ -287,12 +287,6 @@ export function getColumn(matrix, index, result) {
/** The inverse of a rotation-plus-translation matrix (no scale or shear). */
export function inverseTransformation(matrix, result) {
- // This function is an optimized version of the below 4 lines.
- // const rT = Matrix3.transpose(Matrix4.getMatrix3(matrix));
- // const rTN = Matrix3.negate(rT);
- // const rTT = Matrix3.multiplyByVector(rTN, Matrix4.getTranslation(matrix));
- // return Matrix4.fromRotationTranslation(rT, rTT, result);
-
const matrix0 = matrix[0];
const matrix1 = matrix[1];
const matrix2 = matrix[2];
diff --git a/src/geo/scaleToGeodeticSurface.js b/src/geo/scaleToGeodeticSurface.js
index 008e6200a..e729243cc 100644
--- a/src/geo/scaleToGeodeticSurface.js
+++ b/src/geo/scaleToGeodeticSurface.js
@@ -99,8 +99,6 @@ export function scaleToGeodeticSurface(
func = x2 * xMultiplier2 + y2 * yMultiplier2 + z2 * zMultiplier2 - 1.0;
- // "denominator" here refers to the use of this expression in the velocity and acceleration
- // computations in the sections to follow.
denominator =
x2 * xMultiplier3 * oneOverRadiiSquaredX +
y2 * yMultiplier3 * oneOverRadiiSquaredY +
diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js
index ef8e0a762..4fa96767f 100644
--- a/src/globe/cesium/camera.js
+++ b/src/globe/cesium/camera.js
@@ -203,6 +203,12 @@ export function createCesiumCamera(viewer) {
if (!view?.destination)
throw new TypeError('setView needs a destination');
const orientation = cesiumOrientation(view.orientation);
+ // A view replaces a running flight, as on Navara (Cesium's own tween
+ // would otherwise overwrite it on the next frame).
+ if (flight || viewer.scene.tweens?.length > 0) {
+ cesium.cancelFlight();
+ flight = null;
+ }
cesium.setView({
destination: cesiumDestination(view.destination),
orientation,
diff --git a/src/globe/cesium/camera.test.mjs b/src/globe/cesium/camera.test.mjs
index d0e5033ae..c8556a1fc 100644
--- a/src/globe/cesium/camera.test.mjs
+++ b/src/globe/cesium/camera.test.mjs
@@ -516,3 +516,39 @@ test('commanded leaves a newer hook on the camera in place', () => {
remove();
assert.equal(cesium.flyTo, newer);
});
+
+test('setView cancels a running flight before placing the camera', () => {
+ const { camera, cesium } = setup();
+ camera.flyTo({
+ destination: geo.cartesian3.fromDegrees(0, 0, 50000),
+ duration: 2,
+ });
+ const calls = [];
+ cesium.cancelFlight = () => calls.push('cancel');
+ cesium.setView = () => calls.push('place');
+ camera.setView({ destination: geo.cartesian3.fromDegrees(0, 0, 200000) });
+ assert.deepEqual(calls, ['cancel', 'place']);
+});
+
+test('setView without a flight places the camera only', () => {
+ const { camera, cesium } = setup();
+ const calls = [];
+ cesium.cancelFlight = () => calls.push('cancel');
+ cesium.setView = () => calls.push('place');
+ camera.setView({ destination: geo.cartesian3.fromDegrees(0, 0, 200000) });
+ assert.deepEqual(calls, ['place']);
+});
+
+test('setView ends the engine flight through its cancellation callback', () => {
+ const { camera } = setup();
+ const outcome = [];
+ camera.flyTo({
+ destination: geo.cartesian3.fromDegrees(0, 0, 50000),
+ duration: 2,
+ cancel: () => outcome.push('cancel'),
+ complete: () => outcome.push('complete'),
+ });
+ camera.setView({ destination: geo.cartesian3.fromDegrees(0, 0, 200000) });
+ assert.deepEqual(outcome, ['cancel']);
+ assert.equal(camera.flightActive(), false);
+});
diff --git a/src/globe/cesium/ground.js b/src/globe/cesium/ground.js
index 82b4745f6..2cb42aec0 100644
--- a/src/globe/cesium/ground.js
+++ b/src/globe/cesium/ground.js
@@ -492,7 +492,7 @@ export function createCesiumGround(
for (const primitive of drawn) {
primitive.gevGroundSet = options.id;
slot.add(primitive);
- building.add(primitive);
+ if (slot.show) building.add(primitive);
}
watch();
animate();
@@ -505,6 +505,12 @@ export function createCesiumGround(
},
show(visible) {
slot.show = visible;
+ for (const primitive of drawn) {
+ if (visible && (!primitive.ready || !surfaceReady()))
+ building.add(primitive);
+ else building.delete(primitive);
+ }
+ if (building.size) watch();
animate();
requestRender('ground');
},
diff --git a/src/globe/cesium/ground.test.mjs b/src/globe/cesium/ground.test.mjs
index 5210e9c01..00644cfe7 100644
--- a/src/globe/cesium/ground.test.mjs
+++ b/src/globe/cesium/ground.test.mjs
@@ -472,3 +472,30 @@ test('non-pickable ground fills skip pick commands and retain normal drawing', (
port.destroy();
assert.equal(fill.isDestroyed(), true);
});
+
+test('hidden unready ground stops build frames and resumes when shown', () => {
+ const viewer = createFakeViewer();
+ viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
+ const requests = [];
+ const primitive = () => ({
+ ready: false,
+ destroy() {},
+ isDestroyed: () => false,
+ });
+ const part = createCesiumGround(viewer, {
+ factories: { fills: primitive, lines: primitive },
+ requestRender: (why) => requests.push(why),
+ });
+ const set = part.ground.createSet({ id: 'hidden', kind: 'disc' });
+ set.replace([{ center: [0, 0], radiusM: 100, fill: [1, 0, 0, 1] }]);
+ set.commit();
+ set.show(false);
+ requests.length = 0;
+ viewer.scene.postRender.raiseEvent();
+ assert.ok(!requests.includes('ground-build'));
+ set.show(true);
+ requests.length = 0;
+ viewer.scene.postRender.raiseEvent();
+ assert.ok(requests.includes('ground-build'));
+ part.destroy();
+});
diff --git a/src/globe/cesium/imagery.js b/src/globe/cesium/imagery.js
index 02a6bee48..196917a86 100644
--- a/src/globe/cesium/imagery.js
+++ b/src/globe/cesium/imagery.js
@@ -517,6 +517,9 @@ export function createCesiumImagery(
job.collection = collection;
const provider = providerFor(job, job.source);
const requestImage = provider.requestImage.bind(provider);
+ job.restoreRequests = () => {
+ provider.requestImage = requestImage;
+ };
provider.requestImage = (x, y, level, request) => {
if (job.closed) return undefined;
const result = requestImage(x, y, level, request);
@@ -621,6 +624,12 @@ export function createCesiumImagery(
function swapIn(entry, job) {
entry.staging = null;
const layer = job.layer;
+ job.restoreRequests?.();
+ if (layer)
+ Object.defineProperty(layer.imageryProvider, 'credit', {
+ configurable: true,
+ get: () => entry.credit,
+ });
job.layer = null;
closeJob(job);
entry.loading = null;
diff --git a/src/globe/cesium/imageryStageCredit.test.mjs b/src/globe/cesium/imageryStageCredit.test.mjs
new file mode 100644
index 000000000..bc17c55b0
--- /dev/null
+++ b/src/globe/cesium/imageryStageCredit.test.mjs
@@ -0,0 +1,162 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as Cesium from 'cesium';
+import { TILE_RETRY_DELAY_MS, createCesiumImagery } from './imagery.js';
+
+const BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 };
+const tiles = (extra = {}) => ({
+ kind: 'tiles',
+ url: 'https://gibs-{s}.example/{z}/{y}/{x}.png',
+ maxLevel: 12,
+ rectangle: BOX,
+ ...extra,
+});
+const pixels = (width = 4, height = 2) => ({
+ width,
+ height,
+ data: new Uint8ClampedArray(width * height * 4),
+});
+
+/** A tile provider whose requests stay pending until the test settles them. */
+class PendingProvider {
+ constructor(options) {
+ this.options = options;
+ this.calls = [];
+ this.pending = [];
+ // What a Cesium ImageryLayer reads of its provider.
+ this.tilingScheme =
+ options.tilingScheme ?? new Cesium.WebMercatorTilingScheme();
+ this.rectangle = options.rectangle ?? this.tilingScheme.rectangle;
+ this.tileWidth = this.tileHeight = 256;
+ this.minimumLevel = 0;
+ this.maximumLevel = options.maximumLevel ?? 18;
+ this.errorEvent = new Cesium.Event();
+ }
+ requestImage(x, y, level) {
+ this.calls.push([x, y, level]);
+ return new Promise((resolve) => this.pending.push(resolve));
+ }
+}
+
+function manualTimers() {
+ const pending = new Map();
+ let next = 1;
+ return {
+ pending,
+ setTimeout(run, ms) {
+ const id = next++;
+ pending.set(id, { run, ms });
+ return id;
+ },
+ clearTimeout: (id) => pending.delete(id),
+ flush() {
+ const entries = [...pending.values()];
+ pending.clear();
+ for (const entry of entries) entry.run();
+ },
+ };
+}
+
+// `base`: the globe has base imagery under the engine's layers, as the map
+// gives it.
+function fixture({ requestImage, base = true } = {}) {
+ const preRender = new Cesium.Event();
+ const preUpdate = new Cesium.Event();
+ const viewer = {
+ imageryLayers: new Cesium.ImageryLayerCollection(),
+ scene: { globe: { show: true }, preRender, preUpdate, splitPosition: 0.5 },
+ };
+ const renders = [];
+ const timers = manualTimers();
+ const tileset = {
+ show: false,
+ imageryLayers: new Cesium.ImageryLayerCollection(),
+ isDestroyed: () => false,
+ };
+ const providers = [];
+ const { imagery, destroy } = createCesiumImagery(viewer, {
+ requestRender: (reason) => renders.push(reason),
+ tilesets: () => [tileset],
+ createCanvas: (width, height) => ({
+ width,
+ height,
+ getContext: () => ({
+ createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }),
+ putImageData() {},
+ }),
+ }),
+ factories: {
+ tiles(options) {
+ const provider = new PendingProvider(options);
+ if (requestImage) provider.requestImage = requestImage;
+ providers.push(provider);
+ return provider;
+ },
+ },
+ setTimeout: timers.setTimeout,
+ clearTimeout: timers.clearTimeout,
+ });
+ const globe = viewer.imageryLayers;
+ const baseLayer = base
+ ? new Cesium.ImageryLayer(new PendingProvider({}))
+ : null;
+ if (baseLayer) globe.add(baseLayer);
+ // A frame: preUpdate, then (Cesium picks the base layer) preRender.
+ const frame = () => {
+ preUpdate.raiseEvent();
+ preRender.raiseEvent();
+ };
+ return {
+ base: baseLayer,
+ viewer,
+ imagery,
+ destroy,
+ globe,
+ tileset,
+ providers,
+ renders,
+ timers,
+ frame,
+ tileFrames: () => renders.filter((r) => r === 'imagery-tiles').length,
+ };
+}
+
+const layersOf = (collection) =>
+ Array.from({ length: collection.length }, (_, i) => collection.get(i));
+/** The layers on the globe over the fixture's base imagery. */
+const drawnOn = (f) => layersOf(f.globe).filter((layer) => layer !== f.base);
+
+// A fixture whose scene draws frames: postRender, a globe whose tiles load.
+function stagingFixture() {
+ const f = fixture();
+ f.viewer.scene.postRender = new Cesium.Event();
+ f.viewer.scene.globe.tilesLoaded = true;
+ f.render = () => f.viewer.scene.postRender.raiseEvent();
+ return f;
+}
+
+async function swapped(f, layer, extra = {}) {
+ const staged = layer.setSource(
+ tiles({ url: 'https://next.example/{z}/{x}/{y}.png', ...extra }),
+ { staged: true },
+ );
+ await new Promise((resolve) => setImmediate(resolve));
+ const incoming = drawnOn(f)[1];
+ const request = incoming.imageryProvider.requestImage(0, 0, 1);
+ f.providers[1].pending[0]('tile');
+ await request;
+ f.render();
+ f.render();
+ assert.equal(await staged, true);
+ return incoming;
+}
+
+test('after a staged swap a credit-only change reaches the drawn provider', async () => {
+ const f = stagingFixture();
+ const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
+ const incoming = await swapped(f, layer, { credit: 'Old' });
+ layer.setSource(
+ tiles({ url: 'https://next.example/{z}/{x}/{y}.png', credit: 'New' }),
+ );
+ assert.equal(incoming.imageryProvider.credit?.html, 'New');
+});
diff --git a/src/globe/cesium/imageryStageRequests.test.mjs b/src/globe/cesium/imageryStageRequests.test.mjs
new file mode 100644
index 000000000..3c9d11fff
--- /dev/null
+++ b/src/globe/cesium/imageryStageRequests.test.mjs
@@ -0,0 +1,162 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as Cesium from 'cesium';
+import { TILE_RETRY_DELAY_MS, createCesiumImagery } from './imagery.js';
+
+const BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 };
+const tiles = (extra = {}) => ({
+ kind: 'tiles',
+ url: 'https://gibs-{s}.example/{z}/{y}/{x}.png',
+ maxLevel: 12,
+ rectangle: BOX,
+ ...extra,
+});
+const pixels = (width = 4, height = 2) => ({
+ width,
+ height,
+ data: new Uint8ClampedArray(width * height * 4),
+});
+
+/** A tile provider whose requests stay pending until the test settles them. */
+class PendingProvider {
+ constructor(options) {
+ this.options = options;
+ this.calls = [];
+ this.pending = [];
+ // What a Cesium ImageryLayer reads of its provider.
+ this.tilingScheme =
+ options.tilingScheme ?? new Cesium.WebMercatorTilingScheme();
+ this.rectangle = options.rectangle ?? this.tilingScheme.rectangle;
+ this.tileWidth = this.tileHeight = 256;
+ this.minimumLevel = 0;
+ this.maximumLevel = options.maximumLevel ?? 18;
+ this.errorEvent = new Cesium.Event();
+ }
+ requestImage(x, y, level) {
+ this.calls.push([x, y, level]);
+ return new Promise((resolve) => this.pending.push(resolve));
+ }
+}
+
+function manualTimers() {
+ const pending = new Map();
+ let next = 1;
+ return {
+ pending,
+ setTimeout(run, ms) {
+ const id = next++;
+ pending.set(id, { run, ms });
+ return id;
+ },
+ clearTimeout: (id) => pending.delete(id),
+ flush() {
+ const entries = [...pending.values()];
+ pending.clear();
+ for (const entry of entries) entry.run();
+ },
+ };
+}
+
+// `base`: the globe has base imagery under the engine's layers, as the map
+// gives it.
+function fixture({ requestImage, base = true } = {}) {
+ const preRender = new Cesium.Event();
+ const preUpdate = new Cesium.Event();
+ const viewer = {
+ imageryLayers: new Cesium.ImageryLayerCollection(),
+ scene: { globe: { show: true }, preRender, preUpdate, splitPosition: 0.5 },
+ };
+ const renders = [];
+ const timers = manualTimers();
+ const tileset = {
+ show: false,
+ imageryLayers: new Cesium.ImageryLayerCollection(),
+ isDestroyed: () => false,
+ };
+ const providers = [];
+ const { imagery, destroy } = createCesiumImagery(viewer, {
+ requestRender: (reason) => renders.push(reason),
+ tilesets: () => [tileset],
+ createCanvas: (width, height) => ({
+ width,
+ height,
+ getContext: () => ({
+ createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }),
+ putImageData() {},
+ }),
+ }),
+ factories: {
+ tiles(options) {
+ const provider = new PendingProvider(options);
+ if (requestImage) provider.requestImage = requestImage;
+ providers.push(provider);
+ return provider;
+ },
+ },
+ setTimeout: timers.setTimeout,
+ clearTimeout: timers.clearTimeout,
+ });
+ const globe = viewer.imageryLayers;
+ const baseLayer = base
+ ? new Cesium.ImageryLayer(new PendingProvider({}))
+ : null;
+ if (baseLayer) globe.add(baseLayer);
+ // A frame: preUpdate, then (Cesium picks the base layer) preRender.
+ const frame = () => {
+ preUpdate.raiseEvent();
+ preRender.raiseEvent();
+ };
+ return {
+ base: baseLayer,
+ viewer,
+ imagery,
+ destroy,
+ globe,
+ tileset,
+ providers,
+ renders,
+ timers,
+ frame,
+ tileFrames: () => renders.filter((r) => r === 'imagery-tiles').length,
+ };
+}
+
+const layersOf = (collection) =>
+ Array.from({ length: collection.length }, (_, i) => collection.get(i));
+/** The layers on the globe over the fixture's base imagery. */
+const drawnOn = (f) => layersOf(f.globe).filter((layer) => layer !== f.base);
+
+// A fixture whose scene draws frames: postRender, a globe whose tiles load.
+function stagingFixture() {
+ const f = fixture();
+ f.viewer.scene.postRender = new Cesium.Event();
+ f.viewer.scene.globe.tilesLoaded = true;
+ f.render = () => f.viewer.scene.postRender.raiseEvent();
+ return f;
+}
+
+async function swapped(f, layer, extra = {}) {
+ const staged = layer.setSource(
+ tiles({ url: 'https://next.example/{z}/{x}/{y}.png', ...extra }),
+ { staged: true },
+ );
+ await new Promise((resolve) => setImmediate(resolve));
+ const incoming = drawnOn(f)[1];
+ const request = incoming.imageryProvider.requestImage(0, 0, 1);
+ f.providers[1].pending[0]('tile');
+ await request;
+ f.render();
+ f.render();
+ assert.equal(await staged, true);
+ return incoming;
+}
+
+test('after a staged swap the drawn provider still loads new tiles', async () => {
+ const f = stagingFixture();
+ const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
+ const incoming = await swapped(f, layer);
+ f.render();
+ assert.deepEqual(drawnOn(f), [incoming]);
+ const next = incoming.imageryProvider.requestImage(5, 3, 4);
+ assert.ok(next instanceof Promise, 'a new tile after the swap is requested');
+});
diff --git a/src/globe/cesium/maps.js b/src/globe/cesium/maps.js
index 96cd56582..7184aaaea 100644
--- a/src/globe/cesium/maps.js
+++ b/src/globe/cesium/maps.js
@@ -97,6 +97,12 @@ export function createCesiumMaps(
let currentTiles = null;
let creditOwner = null;
let destroyed = false;
+ // Every terrain the globe takes counts, a fallback to the ellipsoid too.
+ let terrainRevision = 0;
+ const offTerrain =
+ viewer.scene.globe?.terrainProviderChanged?.addEventListener(
+ () => terrainRevision++,
+ );
const startupTileset = () =>
(typeof tilesetRef === 'function' ? tilesetRef() : tilesetRef?.current) ??
@@ -214,6 +220,7 @@ export function createCesiumMaps(
},
photorealActive: () => tilesets().some((tileset) => tileset.show === true),
current: () => ({ baseImagery: currentBase, photoreal: currentTiles }),
+ terrainRevision: () => terrainRevision,
};
return {
@@ -224,6 +231,7 @@ export function createCesiumMaps(
if (destroyed) return;
destroyed = true;
abort.abort();
+ offTerrain?.();
mapScene.destroy();
for (const promise of providers.values())
void promise.then(dispose, () => {});
diff --git a/src/globe/cesium/maps.test.mjs b/src/globe/cesium/maps.test.mjs
index c15ecf6ba..90696be9d 100644
--- a/src/globe/cesium/maps.test.mjs
+++ b/src/globe/cesium/maps.test.mjs
@@ -201,6 +201,19 @@ test('setTerrain applies provider and terrain results', async () => {
);
});
+test('terrainRevision counts every terrain the globe takes', async () => {
+ const { viewer, maps, destroy } = setup();
+ assert.equal(maps.terrainRevision(), 0);
+ await maps.setTerrain({ kind: 'ellipsoid' });
+ assert.equal(maps.terrainRevision(), 1);
+ // A fallback applied by other code counts too.
+ viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
+ assert.equal(maps.terrainRevision(), 2);
+ destroy();
+ viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
+ assert.equal(maps.terrainRevision(), 2, 'not counted after destroy');
+});
+
test('setPhotorealTiles shows a given tileset and hides the globe; null reverses it', async () => {
const { viewer, maps } = setup();
const google = tileset();
diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js
index f2d4d81fc..c5b6111e9 100644
--- a/src/globe/cesium/movers.js
+++ b/src/globe/cesium/movers.js
@@ -150,6 +150,7 @@ function spritePart(scene, options, store, ports) {
}
}
};
+ let atlasFailed = false;
// Instances wait for the atlas; then everything written so far is applied.
factories
.cropCells(sprite.atlas, store.cellKeys)
@@ -159,10 +160,14 @@ function spritePart(scene, options, store, ports) {
apply(0, Math.max(store.count, billboards.length));
requestRender('movers');
})
- .catch((error) => console.warn(`[movers] ${id}: atlas failed`, error));
+ .catch((error) => {
+ atlasFailed = true;
+ console.warn(`[movers] ${id}: atlas failed`, error);
+ });
return {
primitive: collection,
ready: () => cells !== null,
+ failed: () => atlasFailed,
commit(range) {
if (cells && range) apply(range.from, range.to);
requestRender('movers');
@@ -226,6 +231,8 @@ function modelPart(options, store, { factories, requestRender, owners }) {
const { model, pickable } = options;
const models = [];
const loading = [];
+ // Instances whose model failed: not tried again (MoverSet.failed).
+ const failed = [];
let destroyed = false;
const place = (item, index) => {
const { orientations } = store;
@@ -282,9 +289,11 @@ function modelPart(options, store, { factories, requestRender, owners }) {
collection.add(item);
requestRender('movers');
})
- .catch((error) =>
- console.warn(`[movers] ${options.id}: model failed`, error),
- );
+ .catch((error) => {
+ if (destroyed) return;
+ failed[index] = true;
+ console.warn(`[movers] ${options.id}: model failed`, error);
+ });
};
return {
primitive: collection,
@@ -296,6 +305,11 @@ function modelPart(options, store, { factories, requestRender, owners }) {
if (!models[at]?.ready) return false;
return true;
},
+ failed(index) {
+ if (index !== undefined) return failed[index] === true;
+ for (let at = 0; at < store.count; at++) if (failed[at]) return true;
+ return false;
+ },
commit(range) {
if (range)
for (let index = range.from; index < range.to; index++) {
@@ -308,6 +322,7 @@ function modelPart(options, store, { factories, requestRender, owners }) {
destroyed = true;
models.length = 0;
loading.length = 0;
+ failed.length = 0;
},
};
}
@@ -360,6 +375,7 @@ export function createCesiumMovers(
return {
primitive,
ready: part.ready,
+ failed: part.failed,
commit: () => part.commit(store.dirty()),
destroy() {
part.destroy();
diff --git a/src/globe/cesium/movers.test.mjs b/src/globe/cesium/movers.test.mjs
index 03406cbf8..bdc8cf43d 100644
--- a/src/globe/cesium/movers.test.mjs
+++ b/src/globe/cesium/movers.test.mjs
@@ -412,6 +412,36 @@ test('a model that loads after its set is destroyed is released', async () => {
assert.equal(root().length, 0);
});
+test('a model that fails to load marks its instance failed, once', async (t) => {
+ t.mock.method(console, 'warn', () => {});
+ let loads = 0;
+ const { movers } = setup({
+ loadModel: async () => {
+ loads++;
+ throw new Error('404');
+ },
+ });
+ const set = movers.createSet({
+ id: 'broken',
+ kind: 'model',
+ capacity: 1,
+ model: { url: '/a.glb' },
+ });
+ set.set(0, { position: at(0, 0) });
+ set.setCount(1);
+ set.commit();
+ assert.equal(set.failed(), false, 'still loading');
+ await settle();
+ assert.equal(set.failed(0), true);
+ assert.equal(set.failed(), true);
+ assert.equal(set.ready(), false);
+ set.commit();
+ await settle();
+ assert.equal(loads, 1, 'not tried again');
+ set.destroy();
+ assert.equal(set.failed(), false, 'a destroyed set reports nothing');
+});
+
test('identify names the set, instance and pickId of a picked billboard or point', async () => {
const { movers, root, identify, viewer } = setup();
// The fake camera is over (0°, ~1°), 7000 km from the centre.
diff --git a/src/globe/cesium/render.js b/src/globe/cesium/render.js
index 62ee70048..51f72a5d0 100644
--- a/src/globe/cesium/render.js
+++ b/src/globe/cesium/render.js
@@ -1,5 +1,6 @@
// Cesium owns its render mode and scene effects. Scene services drive this API.
+import * as Cesium from 'cesium';
import { CONTACT_SONAR_INITIAL, mergeContactSonar } from '../contract.js';
import { createCesiumPostStages } from './postStages.js';
import { createCesiumSonar } from './sonar.js';
@@ -58,6 +59,7 @@ export function createCesiumRender(
viewer.scene.requestRenderMode = !enabled;
},
isContinuous: () => viewer.scene.requestRenderMode === false,
+ maxTextureSize: () => Cesium.ContextLimits.maximumTextureSize,
...effects.render,
setContactSonar(options) {
const next = mergeContactSonar(contactSonar, options);
diff --git a/src/globe/cesium/surface.js b/src/globe/cesium/surface.js
index a17ef5132..d08617179 100644
--- a/src/globe/cesium/surface.js
+++ b/src/globe/cesium/surface.js
@@ -98,6 +98,23 @@ export function createCesiumSurface(
get terrainHeightSupported() {
return Boolean(scene.globe);
},
+ async sampleTerrainMostDetailed(positions) {
+ const provider = scene.terrainProvider;
+ if (!provider || provider instanceof Cesium.EllipsoidTerrainProvider)
+ return positions.map(() => 0);
+ // Cesium reads the finest level per position from it.
+ if (!provider.availability)
+ throw new Error('The terrain has no tile availability');
+ const sampled = await Cesium.sampleTerrainMostDetailed(
+ provider,
+ positions.map(
+ (place) => new Cesium.Cartographic(place.longitude, place.latitude),
+ ),
+ );
+ return sampled.map(({ height }) =>
+ Number.isFinite(height) ? height : undefined,
+ );
+ },
clampToHeight(position, exclude) {
if (!scene.clampToHeightSupported) return undefined;
try {
diff --git a/src/globe/cesium/surface.test.mjs b/src/globe/cesium/surface.test.mjs
index 015ac8488..a7bac90d1 100644
--- a/src/globe/cesium/surface.test.mjs
+++ b/src/globe/cesium/surface.test.mjs
@@ -59,6 +59,21 @@ test('terrainHeight asks the globe', () => {
assert.equal(surface.terrainHeight(place), undefined);
});
+test('sampleTerrainMostDetailed: 0 on the ellipsoid; a terrain without availability rejects', async () => {
+ const { surface, scene } = setup();
+ const place = geo.cartographic.fromDegrees(85.9, 27.9, 0);
+ scene.terrainProvider = new Cesium.EllipsoidTerrainProvider();
+ assert.deepEqual(
+ await surface.sampleTerrainMostDetailed([place, place]),
+ [0, 0],
+ );
+ scene.terrainProvider = { availability: undefined };
+ await assert.rejects(
+ surface.sampleTerrainMostDetailed([place]),
+ /no tile availability/,
+ );
+});
+
test('clampToHeight returns a geo value', () => {
const { viewer, surface } = setup();
const exclude = [];
diff --git a/src/globe/cesium/testing/fakeViewer.mjs b/src/globe/cesium/testing/fakeViewer.mjs
index 8a3fc4548..befefa7b4 100644
--- a/src/globe/cesium/testing/fakeViewer.mjs
+++ b/src/globe/cesium/testing/fakeViewer.mjs
@@ -125,11 +125,13 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
),
),
};
+ let terrainProvider;
const globe = {
show: true,
tilesLoaded: true,
getHeight: record('globe.getHeight', () => 12.5),
pick: record('globe.pick', () => new Cesium.Cartesian3(6378137, 3, 4)),
+ terrainProviderChanged: new Cesium.Event(),
};
const scene = {
camera,
@@ -222,7 +224,14 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
currentTime: Cesium.JulianDate.fromIso8601('2026-10-16T12:00:00Z'),
},
imageryList,
- terrainProvider: undefined,
+ // As Viewer's: the globe takes it and says so.
+ get terrainProvider() {
+ return terrainProvider;
+ },
+ set terrainProvider(provider) {
+ terrainProvider = provider;
+ globe.terrainProviderChanged.raiseEvent(provider);
+ },
imageryLayers: {
add: record('imageryLayers.add', (layer, index) =>
imageryList.splice(index ?? imageryList.length, 0, layer),
diff --git a/src/globe/contract.js b/src/globe/contract.js
index 89b6646eb..4a73c5778 100644
--- a/src/globe/contract.js
+++ b/src/globe/contract.js
@@ -92,7 +92,7 @@
* @property {() => number} height The camera's height above the WGS84
* ellipsoid (the pose's cartographic height), without building a pose.
* @property {(view: { destination: Cartesian3 | Rectangle, orientation?: Orientation }) => void} setView
- * Missing angles default to heading 0, pitch −π/2, roll 0.
+ * Cancels a running flight. Missing angles default to heading 0, pitch −π/2, roll 0.
* @property {(options: FlightOptions) => void} flyTo Cancels a running flight first.
* @property {(sphere: { center: Cartesian3, radius: number }, options?: SphereFlightOptions) => void} flyToSphere
* Ends unlocked (identity transform).
@@ -214,7 +214,13 @@
* @property {(cartographic: Cartographic) => (number | undefined)} terrainHeight
* Height of the terrain/globe surface only (Cesium's globe.getHeight).
* @property {boolean} terrainHeightSupported
- * @property {(position: Cartesian3, exclude?: any[]) => (Cartesian3 | undefined)} clampToHeight
+ * @property {(positions: Cartographic[]) => Promise<(number | undefined)[]>} sampleTerrainMostDetailed
+ * Terrain heights (as terrainHeight) at the terrain's finest level,
+ * fetched where it is not loaded (Cesium's sampleTerrainMostDetailed), one
+ * per position, in order: undefined where no tile answers, 0 on the
+ * ellipsoid. Rejects when the terrain cannot be sampled (Cesium: one
+ * without tile availability). The terrain is the one shown at the call;
+ * maps.terrainRevision tells when it changed since.
* The position moved onto the rendered scene along the geodetic normal.
* @property {boolean} clampToHeightSupported
* @property {() => boolean} tilesLoaded
@@ -237,6 +243,9 @@
* Owns whatever the engine needs to render every frame or only on request
* (Cesium: requestRenderMode and maximumRenderTimeChange = Infinity).
* @property {() => boolean} isContinuous
+ * @property {() => number} maxTextureSize The longest texture side (px) the
+ * GPU takes (Cesium's ContextLimits.maximumTextureSize, three's
+ * capabilities.maxTextureSize): a layer sizes its images under it.
* @property {(options: { enabled?: boolean, amount?: number }) => void} setSharpen
* An unsharp mask on the final image, after tone mapping and before
* anti-aliasing: each pixel moves away from its 3×3 box blur by `amount`
@@ -368,6 +377,9 @@
* @property {() => { baseImagery: object | null, photoreal: object | null }} current
* The source objects on screen, as passed: the base imagery layer on the
* globe and the photoreal tiles shown (null for none).
+ * @property {() => number} terrainRevision Changes whenever the terrain the
+ * globe draws is replaced (a setTerrain applied, a fallback to the
+ * ellipsoid): heights cached against the terrain are stale once it does.
*
* @typedef {object} GlobeClock
* The app's own time (src/time/clock.js), in milliseconds since the Unix
@@ -457,6 +469,9 @@
* sprite set's atlas. With an index, what that instance draws (Cesium: its
* own model; the other engines load a set's glTF once).
* A layer keeps its fallback visual until then.
+ * @property {(index?: number) => boolean} failed Whether what the set draws
+ * failed to load (with an index, what that instance draws): it stays not
+ * ready and is not tried again; a layer that retries makes a new set.
* @property {() => void} destroy
*
* @typedef {object} GlobeMovers
@@ -527,8 +542,7 @@
* with a pickId); false by default: picks pass through to what lies under.
* @property {'both'|'terrain'|'tiles'} [drape] What the fills drape on:
* terrain and 3D tiles ('both', the default), or only one of them
- * (Cesium's classificationType). Navara drapes on whatever surface it
- * draws.
+ * (Cesium's classificationType).
*
* @typedef {object} GroundRadial
* @property {number} fillAlpha Interior alpha multiplier, 0..1.
@@ -679,7 +693,7 @@
* differs only in its credit keeps its pixels. `staged`: the drawn
* source stays until the new one is complete (an image loaded and
* uploaded; the tiles in view loaded — Cesium: its requests in and quiet,
- * at alpha 0 under the drawn one; Navara: its own atlas), then replaces
+ * at alpha 0 above the drawn one; Navara: its own atlas), then replaces
* it; resolves true then, false when it failed (an image or a tile that
* would not load), was aborted by `signal`, or was replaced by another
* setSource first. A hidden layer takes a staged source at once.
@@ -1001,6 +1015,7 @@ const SHAPE = {
request: isFunction,
setContinuous: isFunction,
isContinuous: isFunction,
+ maxTextureSize: isFunction,
setSharpen: isFunction,
sharpen: isFunction,
},
@@ -1010,6 +1025,7 @@ const SHAPE = {
supported: isBoolean,
terrainHeight: isFunction,
terrainHeightSupported: isBoolean,
+ sampleTerrainMostDetailed: isFunction,
clampToHeight: isFunction,
clampToHeightSupported: isBoolean,
tilesLoaded: isFunction,
@@ -1049,6 +1065,7 @@ const SHAPE = {
releasePhotorealTiles: isFunction,
photorealActive: isFunction,
current: isFunction,
+ terrainRevision: isFunction,
},
};
diff --git a/src/globe/ground.js b/src/globe/ground.js
index 78c961b76..cfa2628a8 100644
--- a/src/globe/ground.js
+++ b/src/globe/ground.js
@@ -602,13 +602,28 @@ const GROUND_PICK_PX = 3;
* @param {{ pickGlobe: (at: object) => (object | undefined),
* camera: { livePosition: object, pose: () => { fovy: number } },
* size: () => { width: number, height: number },
- * sets: () => { options: object, items: object[], shown: boolean }[] }} ports
+ * sets: () => { options: object, items: object[], shown: boolean }[],
+ * surface?: () => 'terrain' | 'tiles' }} ports `surface`: what the ground
+ * is now (3D tiles while they show); a set draped only on the other one
+ * draws nothing there and is not picked.
* @returns {(at: { x: number, y: number }) => { onTop: number, order: number, depth: number, result: object }[]}
*/
-export function createGroundPicks({ pickGlobe, camera, size, sets }) {
+export function createGroundPicks({
+ pickGlobe,
+ camera,
+ size,
+ sets,
+ surface = () => 'terrain',
+}) {
return (at) => {
+ const under = surface();
const candidates = sets().filter(
- (state) => state.shown && state.options.pickable && state.items.length,
+ (state) =>
+ state.shown &&
+ state.options.pickable &&
+ state.items.length &&
+ (state.options.drape ?? 'both') !==
+ (under === 'tiles' ? 'terrain' : 'tiles'),
);
if (!candidates.length) return [];
const world = pickGlobe(at);
diff --git a/src/globe/ground.test.mjs b/src/globe/ground.test.mjs
index 18e702f27..5dcc40303 100644
--- a/src/globe/ground.test.mjs
+++ b/src/globe/ground.test.mjs
@@ -394,6 +394,44 @@ test('CPU picks find the item under the picked ground, on top first', () => {
assert.deepEqual(ids(), []);
});
+test('CPU picks skip a set draped only on the other surface', () => {
+ const { ground, live } = createGroundRegistry(() => ({
+ commit() {},
+ show() {},
+ destroy() {},
+ }));
+ for (const drape of ['terrain', 'tiles', 'both']) {
+ const set = ground.createSet({
+ id: drape,
+ kind: 'disc',
+ pickable: true,
+ drape,
+ });
+ set.replace([
+ { center: [1, 1], radiusM: 1e4, fill: [1, 1, 1, 1], pickId: drape },
+ ]);
+ set.commit();
+ }
+ let under = 'terrain';
+ const picks = createGroundPicks({
+ pickGlobe: () => geo.cartesian3.fromDegrees(1, 1),
+ camera: {
+ livePosition: geo.cartesian3.fromDegrees(1, 1, 1e5),
+ pose: () => ({ fovy: Math.PI / 3 }),
+ },
+ size: () => ({ width: 1000, height: 1000 }),
+ sets: () => [...live.values()],
+ surface: () => under,
+ });
+ const ids = () =>
+ picks({ x: 0, y: 0 })
+ .map(({ result }) => result.id)
+ .sort();
+ assert.deepEqual(ids(), ['both', 'terrain']);
+ under = 'tiles';
+ assert.deepEqual(ids(), ['both', 'tiles']);
+});
+
test('radial discs copy and validate their alpha envelope', () => {
const radial = {
fillAlpha: 0.105,
diff --git a/src/globe/imagery.js b/src/globe/imagery.js
index f7a1ab11a..aa2efab20 100644
--- a/src/globe/imagery.js
+++ b/src/globe/imagery.js
@@ -394,9 +394,11 @@ export function createImageryRegistry(
if (options.raise === null)
fail('only a raised layer takes a detail');
const detail = normalizeImageryDetail(next ?? null);
+ const pending = detailing !== null;
dropDetail();
if (!detail) {
- if (state.detail === null) return Promise.resolve(true);
+ // A detail still loading is cleared too: the backend may draw it.
+ if (state.detail === null && !pending) return Promise.resolve(true);
state.detail = null;
part.detail?.(null, () => {});
return Promise.resolve(true);
@@ -504,12 +506,23 @@ export const geographicY = (lat) =>
*/
export function imageryTilePlan(box, rectangle, level, scheme = 'webMercator') {
const geographic = scheme === 'geographic';
- let area = { ...box };
- if (rectangle && rectangle.east >= rectangle.west) {
+ const sourceEast = box.east < box.west ? box.east + 360 : box.east;
+ let area = { ...box, east: sourceEast };
+ if (rectangle) {
const degrees = 180 / Math.PI;
+ const west = rectangle.west * degrees;
+ const east =
+ rectangle.east * degrees + (rectangle.east < rectangle.west ? 360 : 0);
+ const spans = [-360, 0, 360]
+ .map((shift) => [
+ Math.max(box.west + shift, west),
+ Math.min(sourceEast + shift, east),
+ ])
+ .filter(([left, right]) => left < right);
+ if (!spans.length) return null;
area = {
- west: Math.max(box.west, rectangle.west * degrees),
- east: Math.min(box.east, rectangle.east * degrees),
+ west: Math.min(...spans.map(([left]) => left)),
+ east: Math.max(...spans.map(([, right]) => right)),
south: Math.max(box.south, rectangle.south * degrees),
north: Math.min(box.north, rectangle.north * degrees),
};
@@ -522,10 +535,7 @@ export function imageryTilePlan(box, rectangle, level, scheme = 'webMercator') {
const rows = 2 ** z;
const x = Math.max(0, Math.floor(toX(area.west) * columns));
const y = Math.max(0, Math.floor(toY(area.north) * rows));
- const right = Math.min(
- columns - 1,
- Math.ceil(toX(area.east) * columns) - 1,
- );
+ const right = Math.ceil(toX(area.east) * columns) - 1;
const bottom = Math.min(rows - 1, Math.ceil(toY(area.south) * rows) - 1);
const width = right - x + 1,
height = bottom - y + 1;
diff --git a/src/globe/imageryAntimeridian.test.mjs b/src/globe/imageryAntimeridian.test.mjs
new file mode 100644
index 000000000..212241441
--- /dev/null
+++ b/src/globe/imageryAntimeridian.test.mjs
@@ -0,0 +1,46 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { imageryTilePlan } from './imagery.js';
+
+const r = Math.PI / 180;
+
+test('a view across the antimeridian plans the tiles it shows, not the whole source', () => {
+ // A view over Fiji, one degree wide across 180°.
+ const view = {
+ west: 179.5 * r,
+ east: -179.5 * r,
+ south: -17 * r,
+ north: -16 * r,
+ };
+ const global = { west: -180, east: 180, south: -85, north: 85 };
+ const plan = imageryTilePlan(global, view, 10);
+ // The same view one degree west (not crossing) plans level 10.
+ assert.ok(
+ plan.z >= 9,
+ `planned level ${plan.z}, a ${plan.width}×${plan.height} mosaic`,
+ );
+});
+
+test('antimeridian plans intersect each side of the source in both tilings', () => {
+ const view = {
+ west: 179.5 * r,
+ east: -179.5 * r,
+ south: -17 * r,
+ north: -16 * r,
+ };
+ for (const scheme of ['webMercator', 'geographic']) {
+ const columns = 2 ** (10 + (scheme === 'geographic' ? 1 : 0));
+ const global = { west: -180, east: 180, south: -85, north: 85 };
+ const plan = imageryTilePlan(global, view, 10, scheme);
+ assert.equal(plan.z, 10);
+ assert.ok(plan.x < columns && plan.x + plan.width > columns);
+ const west = imageryTilePlan({ ...global, west: 179 }, view, 10, scheme);
+ assert.ok(west.x + west.width <= columns);
+ const east = imageryTilePlan({ ...global, east: -179 }, view, 10, scheme);
+ assert.ok(east.x >= columns);
+ assert.equal(
+ imageryTilePlan({ ...global, west: 0, east: 10 }, view, 10, scheme),
+ null,
+ );
+ }
+});
diff --git a/src/globe/imageryDetailCancellation.test.mjs b/src/globe/imageryDetailCancellation.test.mjs
new file mode 100644
index 000000000..4f10b3b30
--- /dev/null
+++ b/src/globe/imageryDetailCancellation.test.mjs
@@ -0,0 +1,32 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createImageryRegistry } from './imagery.js';
+
+const image = { width: 2, height: 2, data: new Uint8ClampedArray(16) };
+
+test('clearing a detail still loading clears it on the engine too', async () => {
+ const details = [];
+ const { imagery } = createImageryRegistry(() => ({
+ source() {},
+ detail: (detail, finish) => details.push([detail, finish]),
+ look() {},
+ destroy() {},
+ }));
+ const raised = imagery.createLayer({
+ id: 'shell',
+ source: { kind: 'image', image },
+ raise: 6000,
+ });
+ const rectangle = { west: 1, south: 1, east: 2, north: 2 };
+ // The engine already holds the detail (Cesium's shell takes it at once);
+ // it has not drawn yet.
+ const first = raised.setDetail({ image, rectangle });
+ assert.equal(await raised.setDetail(null), true);
+ assert.equal(await first, false);
+ assert.equal(raised.detail(), null);
+ assert.equal(details.at(-1)[0], null, 'the engine is told to clear it');
+ // Nothing shown or loading: another clear asks nothing of the engine.
+ const calls = details.length;
+ assert.equal(await raised.setDetail(null), true);
+ assert.equal(details.length, calls);
+});
diff --git a/src/globe/movers.js b/src/globe/movers.js
index 02a0bdacb..e4ece8af8 100644
--- a/src/globe/movers.js
+++ b/src/globe/movers.js
@@ -347,7 +347,7 @@ export function createMoverStore(options) {
*/
export function createMoverSet(
store,
- { id, commit, destroy, ready = () => true },
+ { id, commit, destroy, ready = () => true, failed = () => false },
) {
let destroyed = false;
const alive = () => {
@@ -373,6 +373,8 @@ export function createMoverSet(
// Whether what it draws has loaded (a model's glTF, an atlas image); with
// an index, what that instance draws.
ready: (index) => !destroyed && ready(index) === true,
+ // Whether that load failed for good (the set is not tried again).
+ failed: (index) => !destroyed && failed(index) === true,
destroy() {
if (destroyed) return;
destroyed = true;
@@ -409,6 +411,7 @@ export function createMoverRegistry(build) {
part.destroy();
},
ready: (index) => (part.ready ? part.ready(index) : true),
+ failed: (index) => part.failed?.(index) === true,
});
sets.set(options.id, entry);
return entry.set;
diff --git a/src/globe/navara/cameraChanged.test.mjs b/src/globe/navara/cameraChanged.test.mjs
new file mode 100644
index 000000000..431791e5a
--- /dev/null
+++ b/src/globe/navara/cameraChanged.test.mjs
@@ -0,0 +1,141 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as geo from '../../geo/index.js';
+import { isEngineEvent } from '../contract.js';
+import { orientationVectors } from '../cameraMath.js';
+import { createCameraModel } from './cameraModel.js';
+
+const { cartesian2, cartesian3, math, matrix4 } = geo;
+const near = (actual, expected, tolerance, label) =>
+ assert.ok(
+ Math.abs(actual - expected) <= tolerance,
+ `${label}: ${actual} is not within ${tolerance} of ${expected}`,
+ );
+
+// A stand-in for Navara's camera: setCamera lands on the next update with
+// Navara's ±89.99° pitch clamp; flights resolve when the test says so.
+function createFakePort() {
+ const port = {
+ applied: [],
+ flights: [],
+ stops: 0,
+ frames: 0,
+ drawn: null,
+ width: 1280,
+ height: 800,
+ apply(pose) {
+ port.applied.push(pose);
+ },
+ read: () => port.drawn,
+ fly(pose, options) {
+ let settle;
+ const promise = new Promise((resolve) => (settle = resolve));
+ port.flights.push({ pose, options, settle });
+ return promise;
+ },
+ stop() {
+ port.stops++;
+ },
+ size: () => ({ width: port.width, height: port.height }),
+ requestFrame() {
+ port.frames++;
+ },
+ /** Navara's update: draw a newly applied pose (pitch clamped). */
+ drawnCount: 0,
+ update() {
+ if (port.applied.length === port.drawnCount) return;
+ port.drawnCount = port.applied.length;
+ const pose = port.applied.at(-1);
+ const pitch = Math.max(-89.99, Math.min(89.99, pose.pitch));
+ const position = cartesian3.fromDegrees(pose.lng, pose.lat, pose.height);
+ port.drawn = {
+ position,
+ ...orientationVectors(
+ cartesian3.fromDegrees(pose.lng, pose.lat, 0),
+ math.toRadians(pose.heading),
+ math.toRadians(pitch),
+ math.toRadians(pose.roll),
+ ),
+ };
+ },
+ };
+ return port;
+}
+
+function createClock() {
+ const clock = {
+ time: 0,
+ timers: [],
+ now: () => clock.time,
+ setTimeout(fn, ms) {
+ const timer = { fn, at: clock.time + ms };
+ clock.timers.push(timer);
+ return timer;
+ },
+ clearTimeout(timer) {
+ clock.timers = clock.timers.filter((entry) => entry !== timer);
+ },
+ advance(ms) {
+ clock.time += ms;
+ for (const timer of [...clock.timers])
+ if (timer.at <= clock.time) {
+ clock.clearTimeout(timer);
+ timer.fn();
+ }
+ },
+ };
+ return clock;
+}
+
+function setup() {
+ const port = createFakePort();
+ const clock = createClock();
+ const model = createCameraModel({ port, clock });
+ // One Navara frame: update, preRender sync, postRender events.
+ const frame = (ms = 16) => {
+ clock.advance(ms);
+ port.update();
+ model.sync();
+ model.afterFrame();
+ };
+ return { port, clock, model, camera: model.camera, frame };
+}
+
+// Cesium's Camera._updateCameraChanged: direction angle / (fovy / 2), and
+// heading or roll change / π, each against percentageChanged (0.5).
+test('turning in place raises changed as Cesium does', () => {
+ const { camera, frame } = setup();
+ const destination = cartesian3.fromDegrees(10, 45, 5000);
+ camera.setView({
+ destination,
+ orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 },
+ });
+ frame();
+ frame();
+ let changed = 0;
+ camera.changed.addEventListener(() => changed++);
+ // Tilt from straight down to 45° on the spot: 45° > half the fovy.
+ camera.setView({
+ destination,
+ orientation: { heading: 0, pitch: -(Math.PI / 4), roll: 0 },
+ });
+ frame();
+ assert.equal(changed, 1, 'a 45° tilt in place');
+ // Heading +170° looking straight down (north-up reset): direction unchanged.
+ camera.setView({
+ destination,
+ orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 },
+ });
+ frame();
+ const before = changed;
+ camera.setView({
+ destination,
+ orientation: {
+ heading: math.toRadians(170),
+ pitch: -math.PI_OVER_TWO,
+ roll: 0,
+ },
+ });
+ frame();
+ assert.equal(changed, before + 1, 'a 170° heading turn in the nadir');
+});
diff --git a/src/globe/navara/cameraModel.js b/src/globe/navara/cameraModel.js
index 6f8fe17b9..e26414f35 100644
--- a/src/globe/navara/cameraModel.js
+++ b/src/globe/navara/cameraModel.js
@@ -12,7 +12,9 @@
// wall time (Cesium's cameraEventWaitTime), changed past percentageChanged.
//
// The port is the engine's thin layer over the Navara view (apply, read, fly,
-// stop, size, requestFrame); see createNavaraEngine.js.
+// stop, size, requestFrame, suspendCameraInputs); see createNavaraEngine.js.
+// A flight suspends the camera inputs until it ends, as Cesium's
+// CameraFlightPath turns them off: a gesture cannot cut it short.
import * as geo from '../../geo/index.js';
import { createEngineEvent } from '../contract.js';
@@ -81,6 +83,7 @@ function posesNear(a, b, distance, angle) {
* stop: () => void,
* size: () => { width: number, height: number },
* requestFrame: () => void,
+ * suspendCameraInputs?: () => () => void,
* },
* clock?: { now: () => number, setTimeout: Function, clearTimeout: Function },
* }} options
@@ -124,6 +127,11 @@ export function createCameraModel({
let lastSeen = initial ? copyPose(initial) : null;
let lastFramePose = copyPose(state);
let lastChangedPose = copyPose(state);
+ let changedAngles = headingPitchRoll(
+ state.position,
+ state.direction,
+ state.up,
+ );
let moving = false;
let lastMoveAt = 0;
let moveEndTimer = null;
@@ -175,6 +183,7 @@ export function createCameraModel({
function endFlight(current, outcome) {
if (flight !== current) return;
flight = null;
+ current.resume?.();
if (outcome === 'cancel') current.options.cancel?.();
}
@@ -197,7 +206,7 @@ export function createCameraModel({
options.complete?.();
return;
}
- const current = { options };
+ const current = { options, resume: port.suspendCameraInputs?.() };
flight = current;
// Navara moves the camera from here on; read it back every frame.
pushed = null;
@@ -212,7 +221,7 @@ export function createCameraModel({
(arrived) => {
if (flight !== current || destroyed) return;
if (!arrived) return endFlight(current, 'cancel');
- flight = null;
+ endFlight(current, 'complete');
land(options, target);
options.complete?.();
},
@@ -261,13 +270,28 @@ export function createCameraModel({
}
lastFramePose = pose;
const turn =
- angleBetween(pose.direction, lastChangedPose.direction) / Math.PI;
+ angleBetween(pose.direction, lastChangedPose.direction) /
+ (view().fovy / 2);
const altitude = Math.max(
1,
cartographic.fromCartesian(pose.position, WGS84).height,
);
const shift =
cartesian3.distance(pose.position, lastChangedPose.position) / altitude;
+ const angles = headingPitchRoll(pose.position, pose.direction, pose.up);
+ const delta = (a, b) => {
+ const angle = Math.abs(a - b) % (2 * Math.PI);
+ return Math.min(angle, 2 * Math.PI - angle) / Math.PI;
+ };
+ // Heading and roll are raised on their own, as Camera._updateCameraChanged
+ // does: a turn about the line of sight (heading at nadir, roll) moves
+ // neither the direction nor the position.
+ for (const angle of ['heading', 'roll']) {
+ const amount = delta(angles[angle], changedAngles[angle]);
+ if (amount <= percentageChanged) continue;
+ changedAngles[angle] = angles[angle];
+ events.changed.raiseEvent(amount);
+ }
const amount = Math.max(turn, shift);
if (amount > percentageChanged) {
lastChangedPose = pose;
@@ -311,6 +335,7 @@ export function createCameraModel({
if (!options?.destination)
throw new TypeError('setView needs a destination');
announce();
+ cancelCurrentFlight();
place(viewFor(options.destination, options.orientation));
},
flyTo(options) {
@@ -395,11 +420,6 @@ export function createCameraModel({
camera,
sync,
afterFrame,
- /**
- * Ends a running flight (its `cancel` runs) without camera.commanded:
- * for the user's own gestures, which are not a new camera owner.
- */
- stopFlight: () => cancelCurrentFlight(),
/**
* Whether nothing else drives the camera: no flight and no lookAt target
* (identity transform). The engine's ground floor acts only then.
diff --git a/src/globe/navara/cameraModel.test.mjs b/src/globe/navara/cameraModel.test.mjs
index 32b8960eb..6e6980575 100644
--- a/src/globe/navara/cameraModel.test.mjs
+++ b/src/globe/navara/cameraModel.test.mjs
@@ -525,3 +525,26 @@ test('pose carries the view aspect ratio', () => {
const { width, height } = port.size();
assert.equal(camera.pose().aspectRatio, width / height);
});
+
+test('setView cancels a model flight before its promise settles', async () => {
+ const { camera, port } = setup();
+ const outcome = [];
+ camera.flyTo({
+ destination: cartesian3.fromDegrees(0, 0, 50000),
+ duration: 2,
+ cancel: () => outcome.push('cancel'),
+ complete: () => outcome.push('complete'),
+ });
+ const destination = cartesian3.fromDegrees(0, 0, 200000);
+ camera.setView({ destination });
+ assert.equal(camera.flightActive(), false);
+ assert.deepEqual(outcome, ['cancel']);
+ port.flights[0].settle(true);
+ await Promise.resolve();
+ near(
+ cartesian3.distance(camera.pose().position, destination),
+ 0,
+ 1e-6,
+ 'setView stays',
+ );
+});
diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js
index a8a3b8adc..f5bd521a0 100644
--- a/src/globe/navara/createNavaraEngine.js
+++ b/src/globe/navara/createNavaraEngine.js
@@ -34,6 +34,8 @@ import { createNavaraSurface } from './surface.js';
import { createGroundFloor } from './groundFloor.js';
const { cartesian3, math } = geo;
+// Past the far side of the Earth: a ray from any camera to any tile.
+const TILE_PICK_FAR_M = 2e7;
// Navara's drawn camera (view.camera.raw, a three PerspectiveCamera in
// Earth-fixed metres): matrixWorld columns are right, up, back, position.
@@ -129,6 +131,7 @@ export function createNavaraEngine(
stop: () => view.setCamera({}),
size,
requestFrame: () => view.forceUpdate(),
+ suspendCameraInputs: () => input.input.suspendCameraInputs(),
},
...(clock ? { clock } : {}),
});
@@ -162,6 +165,15 @@ export function createNavaraEngine(
// Between the view's preRender and postRender: a change made then shows
// in the frame being drawn and asks for no other.
let drawing = false;
+ // Navara clears its frame request after the frame it draws, so a
+ // render.request made during one (preRender to the end of postRender) is
+ // made again once that frame is done: the next frame follows, as on Cesium.
+ let frameOpen = false;
+ let requestedInFrame = false;
+ const requestRender = () => {
+ view.forceUpdate();
+ if (frameOpen) requestedInFrame = true;
+ };
const handlers = {
preUpdate: (time) => {
// Before the view decides whether it is idle (internals.js).
@@ -171,6 +183,13 @@ export function createNavaraEngine(
},
preRender: (time) => {
drawing = true;
+ frameOpen = true;
+ queueMicrotask(() => {
+ frameOpen = false;
+ if (!requestedInFrame || destroyed) return;
+ requestedInFrame = false;
+ view.forceUpdate();
+ });
model.sync();
// After the user's camera is read, before anything draws.
followStep.raiseEvent();
@@ -202,11 +221,7 @@ export function createNavaraEngine(
lines: () => linesPort?.pickables?.() ?? [],
resolveBatch: (batchId) => moversPort?.resolvePick?.(batchId) ?? null,
});
- const input = createNavaraInput(view, {
- // A gesture is the user, not a new camera owner: no camera.commanded.
- onCameraInput: () => model.stopFlight(),
- picker: picking,
- });
+ const input = createNavaraInput(view, { picker: picking });
const credits = createNavaraCredits(view);
const maps = createNavaraMaps(view, fetch ? { fetch } : {});
const imagery =
@@ -216,7 +231,9 @@ export function createNavaraEngine(
credits: credits.credits,
photorealActive: () => maps.maps.photorealActive(),
}) ?? null;
- const { surface } = createNavaraSurface(view, hooks);
+ const { surface } = createNavaraSurface(view, hooks, (positions) =>
+ maps.sampleTerrain(positions),
+ );
// Navara's controller zooms through the ground; keep the camera above it.
// With the camera inputs off or suspended nothing zooms, and the app places
// the camera itself (the cockpit eye, 12 m up): the floor's 30 m would
@@ -233,14 +250,28 @@ export function createNavaraEngine(
...(clock ? { now: () => clock.now() } : {}),
});
- // Ground items are picked on the CPU at the globe under the pointer
- // (src/globe/ground.js), under every mover and line.
+ // Ground items are picked on the CPU at the visible surface under the pointer
+ // (src/globe/ground.js), under every mover and line: the 3D tiles while
+ // they show, else the globe. A CPU ray, not the depth read, which answers
+ // with the near plane until a frame after Navara's pick of a click.
+ const tilesUnder = (screen) => {
+ if (!maps.maps.photorealActive()) return undefined;
+ const ray = model.camera.pickRay(screen);
+ return ray
+ ? surface.raycast({
+ origin: ray.origin,
+ direction: ray.direction,
+ far: TILE_PICK_FAR_M,
+ })?.position
+ : undefined;
+ };
const removeGroundPicks = picking.registerMeshPicker(
createGroundPicks({
- pickGlobe: (screen) => picker.pickGlobe(screen),
+ pickGlobe: (screen) => tilesUnder(screen) ?? picker.pickGlobe(screen),
camera: model.camera,
size,
sets: () => groundPort?.pickables?.() ?? [],
+ surface: () => (maps.maps.photorealActive() ? 'tiles' : 'terrain'),
}),
);
const picker = {
@@ -326,6 +357,33 @@ export function createNavaraEngine(
},
}
: {};
+ let maxTextureSize = null;
+ // Movers on screen now: shown, not transparent, their anchor in the view
+ // and over the horizon.
+ const clip = geo.matrix4.create();
+ const at = geo.cartesian3.create();
+ const visibleContacts = () => {
+ model.viewProjection(clip);
+ let shown = 0;
+ for (const { store } of moversPort?.pickables?.() ?? []) {
+ const { positions, shows, colors, count } = store;
+ for (let index = 0; index < count; index++) {
+ if (shows[index] !== 1 || colors[index * 4 + 3] <= 0) continue;
+ at.x = positions[index * 3];
+ at.y = positions[index * 3 + 1];
+ at.z = positions[index * 3 + 2];
+ const w = clip[3] * at.x + clip[7] * at.y + clip[11] * at.z + clip[15];
+ if (w <= 0) continue;
+ const x =
+ (clip[0] * at.x + clip[4] * at.y + clip[8] * at.z + clip[12]) / w;
+ const y =
+ (clip[1] * at.x + clip[5] * at.y + clip[9] * at.z + clip[13]) / w;
+ if (Math.abs(x) > 1 || Math.abs(y) > 1) continue;
+ if (model.horizonVisible(at)) shown++;
+ }
+ }
+ return shown;
+ };
let bloom = { ...BLOOM_INITIAL };
const bloomRender = bloomPort
? {
@@ -385,11 +443,19 @@ export function createNavaraEngine(
},
frames,
render: {
- request: () => view.forceUpdate(),
+ request: requestRender,
setContinuous(enabled) {
view.animation = Boolean(enabled);
},
isContinuous: () => view.animation === true,
+ // The canvas's own context (getContext hands back the one the view
+ // made); Navara has no public renderer to ask.
+ maxTextureSize: () =>
+ (maxTextureSize ??= (() => {
+ const gl =
+ canvas.getContext?.('webgl2') ?? canvas.getContext?.('webgl');
+ return gl?.getParameter(gl.MAX_TEXTURE_SIZE) ?? 0;
+ })()),
setSharpen(options) {
const next = mergeSharpen(sharpen, options);
const changed =
@@ -415,6 +481,13 @@ export function createNavaraEngine(
ground: groundPort.ground,
meshes: meshes.meshes,
imagery: imagery.imagery,
+ // Not in the contract, as Cesium's (src/cyberSonarEngine.js reads it):
+ // the treatment runs in the movers' shaders; `contacts` is how many are
+ // on screen, so an empty view lets the sonar go idle.
+ contactSonarDiagnostics: () => ({
+ supported: true,
+ contacts: visibleContacts(),
+ }),
destroy() {
if (destroyed) return;
destroyed = true;
diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs
index cfa481c2c..7faea5415 100644
--- a/src/globe/navara/createNavaraEngine.test.mjs
+++ b/src/globe/navara/createNavaraEngine.test.mjs
@@ -446,7 +446,79 @@ test("Navara's fov follows Cesium's vertical fov, also after a resize", () => {
near(engine.camera.pose().fovy, Math.PI / 3, 1e-15, 'pose fovy');
});
-test('cancelFlight stops Navara with an empty setCamera; user input cancels a flight', () => {
+test('contactSonarDiagnostics counts the movers on screen', () => {
+ const view = fakeView();
+ // The camera is 10 000 km over (0°, 0°), looking down.
+ const places = [
+ [0, 0, 1],
+ [1, 1, 1],
+ [0, 0, 0],
+ [180, 0, 1],
+ ];
+ const positions = new Float64Array(places.length * 3);
+ const colors = new Float32Array(places.length * 4).fill(1);
+ const shows = new Uint8Array(places.length).fill(1);
+ places.forEach(([lon, lat, show], index) => {
+ const position = cartesian3.fromDegrees(lon, lat, 0);
+ positions.set([position.x, position.y, position.z], index * 3);
+ shows[index] = show;
+ });
+ colors[1 * 4 + 3] = 0;
+ const store = { positions, colors, shows, count: places.length };
+ const engine = createNavaraEngine(view, {
+ ...ports,
+ movers: { ...ports.movers, pickables: () => [{ store }] },
+ hooks,
+ });
+ // Shown and opaque, in view: the first; the second is transparent, the
+ // third hidden, the fourth behind the Earth.
+ assert.deepEqual(engine.contactSonarDiagnostics(), {
+ supported: true,
+ contacts: 1,
+ });
+ engine.destroy();
+});
+
+test("maxTextureSize reads the canvas's WebGL limit once", () => {
+ const view = fakeView();
+ const asked = [];
+ view.canvas.getContext = (type) => {
+ asked.push(type);
+ return type === 'webgl2'
+ ? { MAX_TEXTURE_SIZE: 0x0d33, getParameter: () => 8192 }
+ : null;
+ };
+ const engine = createNavaraEngine(view, { ...ports, hooks });
+ assert.equal(engine.render.maxTextureSize(), 8192);
+ assert.equal(engine.render.maxTextureSize(), 8192);
+ assert.deepEqual(asked, ['webgl2']);
+ engine.destroy();
+});
+
+test('a render requested while a frame draws asks for the next frame', async () => {
+ const { engine, view } = setup();
+ const requests = () =>
+ view.calls.filter(([name]) => name === 'forceUpdate').length;
+ // The wind's flow asks for its next frame from preRender.
+ const off = engine.frames.preRender.addEventListener(() =>
+ engine.render.request('wind'),
+ );
+ view.emit('preRender', 1);
+ view.emit('postRender', 1);
+ // Navara clears its request flag once the frame is drawn.
+ const drawn = requests();
+ await Promise.resolve();
+ assert.equal(requests(), drawn + 1, 'requested again after the frame');
+ off();
+ view.emit('preRender', 2);
+ view.emit('postRender', 2);
+ const idle = requests();
+ await Promise.resolve();
+ assert.equal(requests(), idle, 'no request, no next frame');
+ engine.destroy();
+});
+
+test('cancelFlight stops Navara with an empty setCamera; user input leaves a flight running', () => {
const { engine, view } = setup();
const outcome = [];
engine.camera.flyTo({
@@ -463,8 +535,9 @@ test('cancelFlight stops Navara with an empty setCamera; user input cancels a fl
cancel: () => outcome.push('input'),
});
view.canvas.dispatchEvent(new Event('wheel'));
- assert.deepEqual(outcome, ['cancel', 'input']);
- assert.equal(engine.camera.flightActive(), false);
+ assert.deepEqual(outcome, ['cancel']);
+ assert.equal(engine.camera.flightActive(), true);
+ engine.destroy();
});
test('pickPosition reads Navara depth at client coordinates; pick finds no tiles', () => {
@@ -536,7 +609,7 @@ test('pickGlobe meets the terrain along the pick ray, or the ellipsoid', () => {
);
});
-test('a user gesture cancels a flight without raising camera.commanded', () => {
+test('a user gesture leaves a flight running without raising camera.commanded', () => {
const { engine, view } = setup();
const outcome = [];
let commanded = 0;
@@ -548,8 +621,8 @@ test('a user gesture cancels a flight without raising camera.commanded', () => {
engine.camera.commanded.addEventListener(() => commanded++);
view.canvas.dispatchEvent(new Event('pointerdown'));
view.canvas.dispatchEvent(new Event('wheel'));
- assert.deepEqual(outcome, ['cancel']);
- assert.equal(engine.camera.flightActive(), false);
+ assert.deepEqual(outcome, []);
+ assert.equal(engine.camera.flightActive(), true);
assert.equal(commanded, 0, 'a gesture is the user, not a new camera owner');
});
@@ -630,17 +703,39 @@ test('the floor stands aside while the camera inputs are off (the cockpit drives
engine.destroy();
});
-test('the floor stands aside while the camera inputs are suspended (the cockpit holds them)', () => {
- const { engine, view } = setup();
- view.sampleTerrainHeight = () => 100;
- const resume = engine.input.suspendCameraInputs();
- engine.camera.setView({
- destination: cartesian3.fromDegrees(2.35, 48.85, 112),
+test('ground picks use the visible surface and fall back to the globe', async () => {
+ const { normalizeGroundItem } = await import('../ground.js');
+ const view = fakeView();
+ // Right after a click Navara's depth read answers with its near plane.
+ view.pickDepthPosition = () => cartesian3.fromDegrees(10.5, 10, 45900);
+ const options = { id: 'rooftop', kind: 'disc', pickable: true, order: 0 };
+ const item = normalizeGroundItem(
+ 'disc',
+ { center: [10, 10], radiusM: 100, fill: [1, 0, 0, 1], pickId: 'roof' },
+ 0,
+ );
+ const engine = createNavaraEngine(view, {
+ ...ports,
+ hooks,
+ ground: {
+ ...ports.ground,
+ pickables: () => [{ options, items: [item], shown: true }],
+ },
});
- view.emit('preRender', 1);
- near(heightOf(engine), 112, 1e-6, 'the cockpit eye 12 m up is kept');
- resume();
- view.emit('preRender', 2);
- near(heightOf(engine), 130, 1e-6, 'lifted once the inputs are back');
+ const at = { x: 640, y: 400 };
+ let globe = cartesian3.fromDegrees(10, 10, 0);
+ engine.picker.pickGlobe = () => globe;
+ let tiles = null;
+ engine.surface.raycast = () => (tiles ? { position: tiles } : undefined);
+ assert.equal(engine.picker.pick(at)?.id, 'roof', 'the globe, not the depth');
+ // Under 3D tiles a tilted view meets the roof before the terrain.
+ engine.maps.photorealActive = () => true;
+ globe = cartesian3.fromDegrees(10.01, 10, 0);
+ tiles = cartesian3.fromDegrees(10, 10, 40);
+ assert.equal(engine.picker.pick(at)?.id, 'roof', 'the tiles shown');
+ tiles = null;
+ assert.equal(engine.picker.pick(at), undefined, 'a tile miss: the globe');
+ globe = cartesian3.fromDegrees(10, 10, 0);
+ assert.equal(engine.picker.pick(at)?.id, 'roof');
engine.destroy();
});
diff --git a/src/globe/navara/flightInput.test.mjs b/src/globe/navara/flightInput.test.mjs
new file mode 100644
index 000000000..f6ba48b5e
--- /dev/null
+++ b/src/globe/navara/flightInput.test.mjs
@@ -0,0 +1,186 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as geo from '../../geo/index.js';
+import { assertGlobeEngine, engineCapabilities } from '../contract.js';
+import { cesiumFovy, orientationVectors } from '../cameraMath.js';
+import { createNavaraEngine } from './createNavaraEngine.js';
+
+const { cartesian2, cartesian3, math } = geo;
+
+// The parts of a Navara ThreeView the engine uses.
+function fakeView() {
+ const handlers = new Map();
+ const canvas = new EventTarget();
+ Object.assign(canvas, {
+ clientWidth: 1280,
+ clientHeight: 800,
+ getBoundingClientRect: () => ({ left: 10, top: 20 }),
+ parentElement: { id: 'container' },
+ });
+ const position = cartesian3.fromDegrees(0, 0, 1e7);
+ const { direction, up } = orientationVectors(
+ position,
+ 0,
+ -math.PI_OVER_TWO,
+ 0,
+ );
+ // three's camera.matrixWorld: columns right, up, back (−direction), position.
+ const right = cartesian3.cross(direction, up, cartesian3.create());
+ const elements = [
+ right.x,
+ right.y,
+ right.z,
+ 0,
+ up.x,
+ up.y,
+ up.z,
+ 0,
+ -direction.x,
+ -direction.y,
+ -direction.z,
+ 0,
+ position.x,
+ position.y,
+ position.z,
+ 1,
+ ];
+ const view = {
+ canvas,
+ animation: false,
+ calls: [],
+ flights: [],
+ on(name, fn) {
+ if (!handlers.has(name)) handlers.set(name, new Set());
+ handlers.get(name).add(fn);
+ },
+ off(name, fn) {
+ handlers.get(name)?.delete(fn);
+ },
+ emit(name, ...args) {
+ for (const fn of [...(handlers.get(name) ?? [])]) fn(...args);
+ },
+ listenerCount: () =>
+ [...handlers.values()].reduce((count, set) => count + set.size, 0),
+ camera: {
+ raw: { matrixWorld: { elements }, updateMatrixWorld() {} },
+ fovs: [],
+ set fov(value) {
+ view.camera.fovs.push(value);
+ },
+ set options(value) {
+ view.calls.push(['options', value]);
+ },
+ },
+ setCamera(pose) {
+ view.calls.push(['setCamera', pose]);
+ },
+ flyTo(pose, options) {
+ return new Promise((resolve) =>
+ view.flights.push({ pose, options, resolve }),
+ );
+ },
+ forceUpdate() {
+ view.calls.push(['forceUpdate']);
+ },
+ pickDepthPosition(x, y) {
+ view.calls.push(['pickDepthPosition', x, y]);
+ return x > 100 ? { x: 1, y: 2, z: 3, isVector3: true } : null;
+ },
+ sampleTerrainHeight: () => undefined,
+ addSource: (desc) => ({ id: 's', desc, delete: () => true }),
+ addLayer: (desc) => ({ id: 'l', desc, delete() {} }),
+ attribution: { add() {}, remove() {} },
+ };
+ return view;
+}
+
+const hooks = {
+ tileRoots: () => [],
+ isIdle: () => true,
+ retainTileGeometry() {},
+};
+
+// Primitive ports for tests of engine orchestration.
+const STUB_MOVERS = Object.freeze({
+ createSet() {
+ throw new Error("Navara movers need the view's three runtime (movers/)");
+ },
+ list: () => [],
+});
+const STUB_LINES = Object.freeze({
+ createSegments() {
+ throw new Error("Navara lines need the view's three runtime (lines/)");
+ },
+ createLine() {
+ throw new Error("Navara lines need the view's three runtime (lines/)");
+ },
+ list: () => [],
+});
+const STUB_MESHES = Object.freeze({
+ createMesh() {
+ throw new Error('Navara meshes need the view runtime');
+ },
+ list: () => [],
+});
+const STUB_GROUND = Object.freeze({
+ createPulseField() {
+ throw new Error('Navara ground needs the view runtime');
+ },
+ createSet() {
+ throw new Error("Navara ground needs the view's three runtime (ground/)");
+ },
+ list: () => [],
+});
+const STUB_IMAGERY = Object.freeze({
+ createLayer() {
+ throw new Error("Navara imagery needs the view's three runtime (imagery/)");
+ },
+ list: () => [],
+ setSplitPosition() {},
+ splitPosition: () => 0.5,
+ surface: () => 'globe',
+});
+
+const ports = {
+ movers: { movers: STUB_MOVERS, destroy() {} },
+ lines: { lines: STUB_LINES, destroy() {} },
+ ground: { ground: STUB_GROUND, destroy() {} },
+ imagery: () => ({ imagery: STUB_IMAGERY, destroy() {} }),
+ meshes: () => ({ meshes: STUB_MESHES, destroy() {} }),
+};
+
+const setup = () => {
+ const view = fakeView();
+ const engine = createNavaraEngine(view, { ...ports, hooks });
+ return { view, engine };
+};
+
+// Cesium's CameraFlightPath turns the camera inputs off for the flight, so a
+// gesture never cancels it; layers/infrastructure suspends the inputs for the
+// same reason ("Camera input would cancel the flight").
+test('a wheel during a flight with the camera inputs suspended does not cancel it', async () => {
+ const { engine, view } = setup();
+ const outcome = [];
+ const restore = engine.input.suspendCameraInputs();
+ engine.camera.flyTo({
+ destination: cartesian3.fromDegrees(100, 0, 5e6),
+ duration: 1.5,
+ complete: () => outcome.push('complete'),
+ cancel: () => outcome.push('cancel'),
+ });
+ view.canvas.dispatchEvent(new Event('wheel'));
+ view.canvas.dispatchEvent(new Event('pointerdown'));
+ assert.deepEqual(outcome, [], 'the flight goes on');
+ assert.equal(engine.camera.flightActive(), true);
+ restore();
+ assert.equal(
+ engine.input.cameraInputsEnabled(),
+ false,
+ 'the flight keeps its own suspension',
+ );
+ view.flights[0].resolve(true);
+ await Promise.resolve();
+ assert.deepEqual(outcome, ['complete']);
+ assert.equal(engine.input.cameraInputsEnabled(), true);
+ engine.destroy();
+});
diff --git a/src/globe/navara/followGestures.js b/src/globe/navara/followGestures.js
index c4becaa9d..3a2438a75 100644
--- a/src/globe/navara/followGestures.js
+++ b/src/globe/navara/followGestures.js
@@ -9,7 +9,9 @@
// window ratio uses Cesium's maximumMovementRatio cap; a drag
// right turns the heading clockwise;
// - right drag: a dolly by dy / height (down closes in);
-// - wheel: one notch (deltaY 100) is 20 % of the range.
+// - wheel: one notch (deltaY 100) is 20 % of the range;
+// - two-finger pinch: the range scales by the fingers' distance ratio
+// (pointers are told apart by pointerId).
const WHEEL_STEP = 1.2;
const ROTATE_RATE = 1.77;
@@ -29,16 +31,32 @@ export function navaraFollowGestures(
) {
return ({ orbit, zoom, dolly }) => {
const resume = suspendCameraInputs();
- let last = null;
+ const pointers = new Map();
+ const distance = () => {
+ const [a, b] = pointers.values();
+ return Math.hypot(a.x - b.x, a.y - b.y);
+ };
const down = (event) => {
if (event.button !== 0 && event.button !== 2) return;
- last = { x: event.clientX, y: event.clientY, button: event.button };
+ pointers.set(event.pointerId, {
+ x: event.clientX,
+ y: event.clientY,
+ button: event.button,
+ });
};
const move = (event) => {
+ const last = pointers.get(event.pointerId);
if (!last) return;
+ const before = pointers.size === 2 ? distance() : 0;
const dx = event.clientX - last.x;
const dy = event.clientY - last.y;
- last = { ...last, x: event.clientX, y: event.clientY };
+ last.x = event.clientX;
+ last.y = event.clientY;
+ if (pointers.size > 1) {
+ const after = pointers.size === 2 ? distance() : 0;
+ if (before > 0 && after > 0) zoom(before / after);
+ return;
+ }
const width = canvas.clientWidth || 1;
const height = canvas.clientHeight || 1;
if (last.button === 2) {
@@ -50,8 +68,8 @@ export function navaraFollowGestures(
ROTATE_RATE * Math.min(-dy / height, MAX_MOVEMENT_RATIO) * Math.PI,
);
};
- const up = () => {
- last = null;
+ const up = (event) => {
+ pointers.delete(event.pointerId);
};
const wheel = (event) => {
if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return;
@@ -63,6 +81,7 @@ export function navaraFollowGestures(
canvas.addEventListener('pointerdown', down);
windowTarget.addEventListener('pointermove', move);
windowTarget.addEventListener('pointerup', up);
+ windowTarget.addEventListener('pointercancel', up);
canvas.addEventListener('wheel', wheel, { passive: false });
canvas.addEventListener('contextmenu', contextMenu);
return () => {
@@ -70,6 +89,8 @@ export function navaraFollowGestures(
canvas.removeEventListener('pointerdown', down);
windowTarget.removeEventListener('pointermove', move);
windowTarget.removeEventListener('pointerup', up);
+ windowTarget.removeEventListener('pointercancel', up);
+ pointers.clear();
canvas.removeEventListener('wheel', wheel, { passive: false });
resume();
};
diff --git a/src/globe/navara/followPinch.test.mjs b/src/globe/navara/followPinch.test.mjs
new file mode 100644
index 000000000..552361768
--- /dev/null
+++ b/src/globe/navara/followPinch.test.mjs
@@ -0,0 +1,68 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { navaraFollowGestures } from './followGestures.js';
+
+const event = (type, fields = {}) =>
+ Object.assign(new Event(type, { cancelable: true }), fields);
+
+test('a two-finger pinch while following zooms and does not jerk the heading', () => {
+ const canvas = new EventTarget();
+ canvas.clientWidth = 400;
+ canvas.clientHeight = 800;
+ const win = new EventTarget();
+ const gestures = navaraFollowGestures(canvas, () => () => {}, win);
+ const calls = [];
+ const release = gestures({
+ orbit: (h, p) => calls.push(['orbit', h, p]),
+ zoom: (f) => calls.push(['zoom', f]),
+ dolly: (r) => calls.push(['dolly', r]),
+ });
+ const touch = (type, pointerId, x, y, target = win) =>
+ target.dispatchEvent(
+ event(type, {
+ pointerId,
+ pointerType: 'touch',
+ isPrimary: pointerId === 1,
+ button: type === 'pointermove' ? -1 : 0,
+ clientX: x,
+ clientY: y,
+ }),
+ );
+ // Two fingers 200 px apart spread out symmetrically (a pinch-out).
+ touch('pointerdown', 1, 100, 400, canvas);
+ touch('pointerdown', 2, 300, 400, canvas);
+ for (let i = 1; i <= 4; i++) {
+ touch('pointermove', 1, 100 - 5 * i, 400);
+ touch('pointermove', 2, 300 + 5 * i, 400);
+ }
+ const turn = calls
+ .filter(([kind]) => kind === 'orbit')
+ .reduce((most, [, h]) => Math.max(most, Math.abs(h)), 0);
+ assert.ok(
+ turn < 0.2,
+ `a pinch turned the heading by up to ${turn.toFixed(2)} rad in one move`,
+ );
+ assert.ok(
+ calls.some(([kind]) => kind === 'zoom' || kind === 'dolly'),
+ 'a pinch changes the range',
+ );
+ touch('pointercancel', 2, 320, 400);
+ const count = calls.length;
+ touch('pointermove', 2, 350, 400);
+ assert.equal(
+ calls.length,
+ count,
+ 'a cancelled finger no longer moves the camera',
+ );
+ touch('pointermove', 1, 75, 400);
+ assert.equal(
+ calls.at(-1)[0],
+ 'orbit',
+ 'the remaining finger resumes dragging',
+ );
+ touch('pointerup', 1, 75, 400);
+ const ended = calls.length;
+ touch('pointermove', 1, 90, 400);
+ assert.equal(calls.length, ended);
+ release();
+});
diff --git a/src/globe/navara/ground/index.js b/src/globe/navara/ground/index.js
index 42a9392d3..989bbf304 100644
--- a/src/globe/navara/ground/index.js
+++ b/src/globe/navara/ground/index.js
@@ -61,6 +61,7 @@ export function createNavaraGround(
};
const pack = (entry) => {
entry.pack = packSet(entry.options.kind, entry.items, { terrainHeight });
+ entry.pack.drape = entry.options.drape;
for (
let at = REVEAL_OFFSET;
at < entry.pack.items.length;
diff --git a/src/globe/navara/ground/index.test.mjs b/src/globe/navara/ground/index.test.mjs
index c45c7cfbb..8ae696d52 100644
--- a/src/globe/navara/ground/index.test.mjs
+++ b/src/globe/navara/ground/index.test.mjs
@@ -274,3 +274,22 @@ test('destroying a revealed corridor leaves the next ground set with fresh pass
}
port.destroy();
});
+
+test('ground sets carry their surface classification into the frame texture', async () => {
+ const { matrix4 } = await import('../../../geo/index.js');
+ const pass = fakePass();
+ const port = createNavaraGround(fakeView(), { createPass: () => pass });
+ for (const [drape, expected] of [
+ ['terrain', 1],
+ ['tiles', 2],
+ ['both', 0],
+ ]) {
+ const set = port.ground.createSet({ id: drape, kind: 'polygon', drape });
+ set.replace([square(0)]);
+ set.commit();
+ const state = pass.owner.state;
+ state.update(matrix4.IDENTITY, matrix4.IDENTITY, 800, 600);
+ assert.equal(state.frames.data[(state.count - 1) * 16 + 15], expected);
+ }
+ port.destroy();
+});
diff --git a/src/globe/navara/ground/pack.js b/src/globe/navara/ground/pack.js
index e64c1f780..5c47fa20b 100644
--- a/src/globe/navara/ground/pack.js
+++ b/src/globe/navara/ground/pack.js
@@ -524,6 +524,7 @@ export function composeGround(packs) {
0,
);
const frames64 = new Float64Array(count * 13);
+ const drapes = new Uint8Array(count);
const volumes = new Float64Array(count * 4);
const heights = new Float64Array(count * 2);
const styles = texelStorage(count * STYLE_TEXELS);
@@ -543,6 +544,11 @@ export function composeGround(packs) {
let run = 0;
let look = 0;
for (const pack of packs) {
+ drapes.fill(
+ pack.drape === 'terrain' ? 1 : pack.drape === 'tiles' ? 2 : 0,
+ item,
+ item + pack.count,
+ );
frames64.set(pack.frames, item * 13);
volumes.set(pack.volumes, item * 4);
heights.set(pack.heights, item * 2);
@@ -585,6 +591,7 @@ export function composeGround(packs) {
const box = new Float64Array(4);
const lastView = new Float64Array(16).fill(Number.NaN);
const lastProjection = new Float64Array(16).fill(Number.NaN);
+ let lastPixelRatio = 1;
let lastWidth = 0;
let lastHeight = 0;
const clampTo = (value, max) => Math.min(max - 1, Math.max(0, value));
@@ -602,8 +609,11 @@ export function composeGround(packs) {
return candidates;
},
/** True when the frames, grid or candidates changed (upload them). */
- update(view, projection, width, height) {
- let changed = width !== lastWidth || height !== lastHeight;
+ update(view, projection, width, height, pixelRatio = 1) {
+ let changed =
+ width !== lastWidth ||
+ height !== lastHeight ||
+ pixelRatio !== lastPixelRatio;
for (let index = 0; index < 16 && !changed; index++)
changed =
view[index] !== lastView[index] ||
@@ -613,6 +623,7 @@ export function composeGround(packs) {
lastProjection.set(projection);
lastWidth = width;
lastHeight = height;
+ lastPixelRatio = pixelRatio;
const m = view;
counts.fill(0);
for (let index = 0; index < count; index++) {
@@ -636,11 +647,12 @@ export function composeGround(packs) {
frames.data.set(viewFrame.subarray(3, 6), t + 4);
frames.data.set(viewFrame.subarray(6, 9), t + 8);
frames.data.set(viewFrame.subarray(9, 12), t + 12);
+ frames.data[t + 15] = drapes[index];
const v = index * 4;
// The item's extent in its plane and its padding in pixels, for
// the shader's early rejection.
frames.data[t + 7] = volumes[v];
- frames.data[t + 11] = volumes[v + 3];
+ frames.data[t + 11] = volumes[v + 3] * pixelRatio;
const distance = Math.hypot(viewFrame[0], viewFrame[1], viewFrame[2]);
// Heights relative to the plane's origin on the ellipsoid; the
// plane dips under the curve by r²/2R at the item's edge.
@@ -665,8 +677,8 @@ export function composeGround(packs) {
ranges[r] = -1;
continue;
}
- const padX = (2 * volumes[v + 3]) / Math.max(1, width);
- const padY = (2 * volumes[v + 3]) / Math.max(1, height);
+ const padX = (2 * volumes[v + 3] * pixelRatio) / Math.max(1, width);
+ const padY = (2 * volumes[v + 3] * pixelRatio) / Math.max(1, height);
ranges[r] = clampTo(
Math.floor(((box[0] - padX) * 0.5 + 0.5) * GRID_COLS),
GRID_COLS,
diff --git a/src/globe/navara/ground/pack.test.mjs b/src/globe/navara/ground/pack.test.mjs
index 253e07c88..38707d186 100644
--- a/src/globe/navara/ground/pack.test.mjs
+++ b/src/globe/navara/ground/pack.test.mjs
@@ -502,3 +502,22 @@ test('radial discs pack both transitions, alpha and emission without segment dat
);
assert.equal(pack.segments.length, 0);
});
+
+test('screen padding follows the render pixel ratio', () => {
+ const pack = packSet(
+ 'disc',
+ items('disc', [
+ {
+ center: [0, 0],
+ radiusM: 100,
+ outline: { color: [1, 1, 1, 1], widthPx: 8 },
+ },
+ ]),
+ );
+ const state = composeGround([pack]);
+ const identity = geo.matrix4.IDENTITY;
+ state.update(identity, identity, 800, 600, 1);
+ const padding = state.frames.data[11];
+ assert.equal(state.update(identity, identity, 800, 600, 2), true);
+ assert.equal(state.frames.data[11], padding * 2);
+});
diff --git a/src/globe/navara/ground/pass.js b/src/globe/navara/ground/pass.js
index 3927e8ab6..21545e6d9 100644
--- a/src/globe/navara/ground/pass.js
+++ b/src/globe/navara/ground/pass.js
@@ -44,6 +44,7 @@ const float = (value) => (Number.isInteger(value) ? `${value}.0` : `${value}`);
const fragmentShader = /* glsl */ `
uniform sampler2D tAllDepth;
uniform sampler2D tSurfaceDepth;
+uniform sampler2D tGlobeDepth;
uniform highp sampler2D tFrames;
uniform highp sampler2D tStyles;
uniform highp sampler2D tSegments;
@@ -73,7 +74,7 @@ vec3 blendCorridor(vec3 color, int item, float nearest, vec2 normal, float along
// Metres a pixel spans across the line (the screen gradient of the
// distance to it): its width in pixels the same at any angle, as Cesium's.
float pixel = max(length(vec2(dot(normal, localDx), dot(normal, localDy))), 1e-4);
- float halfWidth = shape.x > 0.0 ? shape.x * 0.5 * pixel : shape.y * 0.5;
+ float halfWidth = shape.x > 0.0 ? shape.x * uPixelRatio * 0.5 * pixel : shape.y * 0.5;
float cover = clamp((halfWidth - nearest) / pixel + 0.5, 0.0, 1.0);
if (cover <= 0.0) return color;
vec4 fill = atSlot(tItems, at);
@@ -137,6 +138,8 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
float surface = texture2D(tSurfaceDepth, uv).r;
// The packed depth also holds forward meshes, which are not ground.
if (depth < surface - 2e-7) return;
+ float globeDepth = unpackRGBAToDepth(texture2D(tGlobeDepth, uv));
+ bool onTiles = surface < globeDepth - 2e-7;
vec3 color = toDisplay(inputColor.rgb);
// The pixel's ground in world space, once: every corridor tile tests the
// same value, so exactly one tile of a set holds it.
@@ -150,7 +153,9 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
vec4 origin = atSlot(tFrames, id * ${TEXELS});
vec4 east = atSlot(tFrames, id * ${TEXELS} + 1);
vec4 north = atSlot(tFrames, id * ${TEXELS} + 2);
- vec3 up = atSlot(tFrames, id * ${TEXELS} + 3).xyz;
+ vec4 surfaceFrame = atSlot(tFrames, id * ${TEXELS} + 3);
+ if ((surfaceFrame.w == 1.0 && onTiles) || (surfaceFrame.w == 2.0 && !onTiles)) continue;
+ vec3 up = surfaceFrame.xyz;
vec4 span = atSlot(tStyles, id * ${STYLE_TEXELS} + 3);
vec3 delta = point - origin.xyz;
float height = dot(delta, up);
@@ -190,7 +195,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
// A pixel past the bins (beyond an open side) takes the bin nearest
// it, with a margin that reaches it too.
float past = length(max(vec2(0.0), max(place.xy - local, local - open.zw)));
- float need = (lineColor.z * pixelMax + past) / (${float(MARGIN_RATIO)} * shape.y);
+ float need = (lineColor.z * uPixelRatio * pixelMax + past) / (${float(MARGIN_RATIO)} * shape.y);
int level = need <= 1.0 ? 0 : min(int(lineColor.w + 0.5), int(ceil(log2(need))));
int columns = int(shape.z + 0.5);
int rows = int(shape.w + 0.5);
@@ -264,7 +269,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
vec2 centre = vec2(0.0);
vec4 grid = atSlot(tStyles, id * ${STYLE_TEXELS} + 4);
if (grid.x > 0.5 &&
- (length(localDx) + length(localDy)) * (shape.y * 0.5 + 1.5) <= grid.z * 0.5) {
+ (length(localDx) + length(localDy)) * (shape.y * uPixelRatio * 0.5 + 1.5) <= grid.z * 0.5) {
vec2 origin = atSlot(tStyles, id * ${STYLE_TEXELS} + 5).xy;
ivec2 cell = ivec2(floor((local - origin) / grid.z));
// Off the grid: outside the area, beyond any edge's reach.
@@ -317,7 +322,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
if (fillColor.a > 0.0)
color = mix(color, fillColor.rgb, fillColor.a * clamp(inward / pixel + 0.5, 0.0, 1.0));
if (lineColor.a > 0.0 && shape.y > 0.0)
- color = mix(color, lineColor.rgb, lineColor.a * clamp(shape.y * 0.5 - nearest / pixel + 0.5, 0.0, 1.0));
+ color = mix(color, lineColor.rgb, lineColor.a * clamp(shape.y * uPixelRatio * 0.5 - nearest / pixel + 0.5, 0.0, 1.0));
}
outputColor = vec4(toLinear(color), inputColor.a);
}
@@ -401,6 +406,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
...groundSurfaceUniforms(),
+ ['tGlobeDepth', new Uniform(null)],
['tFrames', new Uniform(null)],
['tStyles', new Uniform(null)],
['tSegments', new Uniform(null)],
@@ -425,12 +431,15 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
updateGroundSurface(this.effect, camera, this.ctx, mrt);
const input = this.ctx.getInputBuffer();
const { state } = owner;
+ const cssWidth = this.view.canvas?.clientWidth;
+ const pixelRatio = cssWidth > 0 ? input.width / cssWidth : 1;
if (
state.update(
camera.matrixWorldInverse.elements,
camera.projectionMatrix.elements,
input.width,
input.height,
+ pixelRatio,
)
) {
const textures = owner.textures;
@@ -444,10 +453,10 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
textures.candidates.needsUpdate = true;
}
const u = this.effect.uniforms;
+ u.get('tGlobeDepth').value = mrt.globeDepthBuffer;
u.get('uViewToWorld').value.copy(camera.matrixWorld);
u.get('uTime').value = groundSeconds();
- const cssWidth = this.view.canvas?.clientWidth;
- u.get('uPixelRatio').value = cssWidth > 0 ? input.width / cssWidth : 1;
+ u.get('uPixelRatio').value = pixelRatio;
u.get('tFrames').value = owner.textures.frames;
u.get('tStyles').value = owner.textures.styles;
u.get('tSegments').value = owner.textures.segments;
diff --git a/src/globe/navara/imagery/index.js b/src/globe/navara/imagery/index.js
index 26964b14c..e69031371 100644
--- a/src/globe/navara/imagery/index.js
+++ b/src/globe/navara/imagery/index.js
@@ -345,14 +345,13 @@ export function createNavaraImagery(
current.canvas.width = current.canvas.height = 0;
queue.prune();
};
- // The source's rectangle within the tiling's latitudes; across the
- // antimeridian the atlas covers every longitude.
+ // The source's rectangle within the tiling's latitudes.
const limit = scheme === 'geographic' ? 90 : WEB_MERCATOR_LIMIT;
const box = () => {
const { west, south, east, north } = source.rectangle;
return {
- west: west < east ? west : -180,
- east: west < east ? east : 180,
+ west,
+ east,
south: Math.max(south, -limit),
north: Math.min(north, limit),
};
@@ -416,7 +415,8 @@ export function createNavaraImagery(
tasks.push({
url: imageryTileUrl(
source.url,
- plan.x + x,
+ (plan.x + x) %
+ 2 ** (plan.z + (scheme === 'geographic' ? 1 : 0)),
plan.y + y,
plan.z,
source.subdomains,
@@ -625,7 +625,7 @@ export function createNavaraImagery(
},
);
- const offCamera = camera.changed.addEventListener(() => {
+ const viewChanged = () => {
let changed = false;
for (const entry of entries.values()) {
changed = entry.drawing.cameraChanged() || changed;
@@ -633,12 +633,15 @@ export function createNavaraImagery(
}
// Raised layers rise with the camera.
if (changed || liftNow() !== lift) compose();
- });
+ };
+ const offCamera = camera.changed.addEventListener(viewChanged);
+ view.on?.('resize', viewChanged);
return {
imagery: registry.imagery,
destroy() {
offCamera();
+ view.off?.('resize', viewChanged);
registry.destroyAll();
pass.destroy();
},
diff --git a/src/globe/navara/imagery/index.test.mjs b/src/globe/navara/imagery/index.test.mjs
index 7e167d4a9..69a68e6a0 100644
--- a/src/globe/navara/imagery/index.test.mjs
+++ b/src/globe/navara/imagery/index.test.mjs
@@ -6,7 +6,7 @@ import { MAX_IMAGERY_SLOTS, createNavaraImagery } from './index.js';
const BOX = { west: -97.77, south: 30.25, east: -97.71, north: 30.29 };
const tick = () => new Promise((resolve) => setImmediate(resolve));
-function fixture() {
+function fixture(rectangle = null) {
const canvases = [];
const draws = [];
const jobs = [];
@@ -15,7 +15,11 @@ function fixture() {
const changed = createEngineEvent();
let photoreal = false;
let frames = 0;
+ let height = 800;
+ const events = new Map();
const view = {
+ on: (name, run) => events.set(name, run),
+ off: (name) => events.delete(name),
forceUpdate: () => frames++,
};
const { imagery, destroy } = createNavaraImagery(view, {
@@ -25,9 +29,9 @@ function fixture() {
cartographic: { height: 12_000, latitude: 0.53 },
fovy: 0.7,
}),
- viewRectangle: () => null,
+ viewRectangle: () => rectangle,
},
- size: () => ({ width: 1280, height: 800 }),
+ size: () => ({ width: 1280, height }),
credits: {
addStatic(html) {
credits.set(html, (credits.get(html) ?? 0) + 1);
@@ -52,6 +56,10 @@ function fixture() {
},
});
return {
+ resize: (next) => {
+ height = next;
+ events.get('resize')?.();
+ },
imagery,
destroy,
canvases,
@@ -300,3 +308,43 @@ test('a raised image is lifted with the camera; its detail is a slot over it', a
assert.equal(f.last().slots.length, 1);
assert.equal(f.last().slots[0].look[3], 0);
});
+
+test('resizing replans a tile atlas without a camera change', async () => {
+ const f = fixture();
+ f.imagery.createLayer({ id: 'resize', source: tiles({ maxLevel: 20 }) });
+ await tick();
+ const before = f.canvases.length;
+ f.resize(1600);
+ await tick();
+ assert.ok(f.canvases.length > before);
+ f.destroy();
+});
+
+test('an atlas crossing the antimeridian requests valid columns on both sides', async () => {
+ const r = Math.PI / 180;
+ const f = fixture({
+ west: 179.5 * r,
+ east: -179.5 * r,
+ south: -17 * r,
+ north: -16 * r,
+ });
+ f.imagery.createLayer({
+ id: 'seam',
+ source: tiles({
+ minLevel: 10,
+ maxLevel: 10,
+ rectangle: { west: -180, east: 180, south: -85, north: 85 },
+ }),
+ });
+ let loaded = 0;
+ for (let i = 0; i < 20; i++) {
+ await tick();
+ while (loaded < f.jobs.length) f.jobs[loaded++].resolve({});
+ }
+ const columns = f.jobs.map(({ url }) =>
+ Number(url.match(/\/(\d+)\.png$/)[1]),
+ );
+ assert.ok(columns.includes(0) && columns.includes(1023));
+ assert.ok(columns.every((x) => x >= 0 && x < 1024));
+ f.destroy();
+});
diff --git a/src/globe/navara/imagery/pass.js b/src/globe/navara/imagery/pass.js
index bb66d7e38..20cd8d9a3 100644
--- a/src/globe/navara/imagery/pass.js
+++ b/src/globe/navara/imagery/pass.js
@@ -91,17 +91,17 @@ vec3 paint(vec3 color, sampler2D image, vec4 rect, vec4 bounds, vec4 look, vec4
if ((look.y < 0.0 && x > uSplit) || (look.y > 0.0 && x < uSplit)) return color;
if (!inRect(geo, rect)) return color;
if (look.w > 0.5 && inRect(geo, cut)) return color;
- float lon = geo.x < rect.x ? geo.x + 360.0 : geo.x;
vec2 at;
vec2 dx;
vec2 dy;
if (look.z > 0.5) {
- vec2 m = vec2(merc.x + (lon - geo.x) / 360.0, merc.y);
+ vec2 m = vec2(merc.x < bounds.x ? merc.x + 1.0 : merc.x, merc.y);
at = (m - bounds.xy) / bounds.zw;
dx = mercDx / bounds.zw;
dy = mercDy / bounds.zw;
} else {
vec2 span = vec2(bounds.z - bounds.x, bounds.y - bounds.w);
+ float lon = geo.x < bounds.x ? geo.x + 360.0 : geo.x;
at = vec2(lon - bounds.x, geo.y - bounds.w) / span;
dx = geoDx / span;
dy = geoDy / span;
diff --git a/src/globe/navara/input.js b/src/globe/navara/input.js
index 51ad3de93..f1afc3e64 100644
--- a/src/globe/navara/input.js
+++ b/src/globe/navara/input.js
@@ -6,15 +6,19 @@ import { createExclusivePointer } from '../exclusivePointer.js';
// that key is held (shift over ctrl over alt), one without only while none is.
//
// Camera inputs map to Navara's controller flags (view.camera.options).
-// Navara 0.2.1 lets a flyTo run on under user input, where Cesium cancels it;
-// `onCameraInput` reports user camera input so the engine can do the same.
+// Flights suspend camera inputs, matching Cesium's flight controller.
import { INPUT_TYPES, normalizeInputOptions } from '../contract.js';
import { createPickDelivery } from '../pickDelivery.js';
const CLICK_PIXEL_TOLERANCE = 5; // Cesium's _clickPixelTolerance
-const SCREEN_EVENTS = ['pointerdown', 'pointerup', 'pointermove', 'dblclick'];
-const CAMERA_EVENTS = ['pointerdown', 'wheel', 'touchstart'];
+const SCREEN_EVENTS = [
+ 'pointerdown',
+ 'pointerup',
+ 'pointermove',
+ 'pointercancel',
+ 'dblclick',
+];
function modifierOf(event) {
if (event.shiftKey) return 'shift';
@@ -25,8 +29,7 @@ function modifierOf(event) {
/**
* @param {{ canvas: HTMLCanvasElement, camera: { options: object } }} view
- * @param {{ onCameraInput?: (type: string) => void,
- * picker?: { clickPick: Function, pick: Function, drillPick: Function },
+ * @param {{ picker?: { clickPick: Function, pick: Function, drillPick: Function },
* scheduleFrame?: (run: () => void) => void }} [options] `picker`
* answers listeners with { pick, drill } (picking.js): clicks from the
* GPU pick of the gesture, moves from the CPU pick once a frame.
@@ -34,11 +37,7 @@ function modifierOf(event) {
*/
export function createNavaraInput(
view,
- {
- onCameraInput = () => {},
- picker = null,
- scheduleFrame = (run) => requestAnimationFrame(run),
- } = {},
+ { picker = null, scheduleFrame = (run) => requestAnimationFrame(run) } = {},
) {
const { canvas } = view;
const listeners = new Map(INPUT_TYPES.map((type) => [type, new Set()]));
@@ -46,6 +45,7 @@ export function createNavaraInput(
let destroyed = false;
let cameraOptions = {};
let downAt = null;
+ const activePointers = new Set();
let last = null;
const pointer = createExclusivePointer();
@@ -92,23 +92,37 @@ export function createNavaraInput(
const position = positionOf(event);
switch (event.type) {
case 'pointerdown':
+ // A primary pointer starts a new gesture: pointers released off the
+ // canvas cannot hold its click back.
+ if (event.isPrimary === true) activePointers.clear();
+ activePointers.add(event.pointerId);
+ if (activePointers.size > 1 || event.isPrimary === false) {
+ downAt = null;
+ break;
+ }
if (event.button !== 0) break;
- downAt = position;
+ downAt = { ...position, pointerId: event.pointerId };
emit('down', event, { position });
break;
case 'pointerup': {
+ activePointers.delete(event.pointerId);
if (event.button !== 0) break;
emit('up', event, { position });
const start = downAt;
downAt = null;
if (
start &&
+ start.pointerId === event.pointerId &&
Math.hypot(position.x - start.x, position.y - start.y) <
CLICK_PIXEL_TOLERANCE
)
emit('click', event, { position });
break;
}
+ case 'pointercancel':
+ activePointers.delete(event.pointerId);
+ downAt = null;
+ break;
case 'pointermove':
emit('move', event, {
startPosition: last ?? position,
@@ -121,15 +135,6 @@ export function createNavaraInput(
}
last = position;
};
- const onCameraEvent = (event) => {
- onCameraInput(event.type);
- };
- for (const type of CAMERA_EVENTS)
- canvas.addEventListener(type, onCameraEvent, {
- capture: true,
- passive: true,
- });
-
let suspended = 0;
const sendCameraInputs = () => {
const enabled = suspended === 0;
@@ -199,8 +204,6 @@ export function createNavaraInput(
if (destroyed) return;
pointer.destroy();
destroyed = true;
- for (const type of CAMERA_EVENTS)
- canvas.removeEventListener(type, onCameraEvent, { capture: true });
if (attached)
for (const name of SCREEN_EVENTS)
canvas.removeEventListener(name, onScreenEvent);
diff --git a/src/globe/navara/input.test.mjs b/src/globe/navara/input.test.mjs
index b95719564..e1f3a45ef 100644
--- a/src/globe/navara/input.test.mjs
+++ b/src/globe/navara/input.test.mjs
@@ -33,7 +33,6 @@ const pointer = (type, x, y, extra = {}) =>
function setup() {
const canvas = fakeCanvas();
const cameraOptions = [];
- const cameraInputs = [];
const view = {
canvas,
camera: {
@@ -42,10 +41,8 @@ function setup() {
},
},
};
- const { input, destroy } = createNavaraInput(view, {
- onCameraInput: (type) => cameraInputs.push(type),
- });
- return { canvas, input, destroy, cameraOptions, cameraInputs };
+ const { input, destroy } = createNavaraInput(view);
+ return { canvas, input, destroy, cameraOptions };
}
test('down, up and click carry canvas-relative positions', () => {
@@ -119,14 +116,9 @@ test('on validates its arguments and returns an idempotent remover', () => {
assert.equal(count, 0);
});
-test('suspending Navara controls preserves user input notifications', () => {
- const { canvas, input, cameraOptions, cameraInputs } = setup();
- canvas.dispatchEvent(pointer('pointerdown', 1, 1));
- canvas.dispatchEvent(new Event('wheel'));
- assert.deepEqual(cameraInputs, ['pointerdown', 'wheel']);
+test("suspending turns Navara's controls off until the resume", () => {
+ const { input, cameraOptions } = setup();
const resume = input.suspendCameraInputs();
- canvas.dispatchEvent(pointer('pointerdown', 1, 1));
- assert.deepEqual(cameraInputs, ['pointerdown', 'wheel', 'pointerdown']);
resume();
assert.deepEqual(cameraOptions, [
{ enableSpin: false, enableZoom: false, enableTilt: false },
diff --git a/src/globe/navara/inputPinch.test.mjs b/src/globe/navara/inputPinch.test.mjs
new file mode 100644
index 000000000..e379fbe44
--- /dev/null
+++ b/src/globe/navara/inputPinch.test.mjs
@@ -0,0 +1,104 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createNavaraInput } from './input.js';
+
+function fakeCanvas() {
+ const canvas = new EventTarget();
+ canvas.getBoundingClientRect = () => ({ left: 100, top: 50 });
+ canvas.listenerCount = 0;
+ const add = canvas.addEventListener.bind(canvas);
+ const remove = canvas.removeEventListener.bind(canvas);
+ canvas.addEventListener = (...args) => {
+ canvas.listenerCount++;
+ add(...args);
+ };
+ canvas.removeEventListener = (...args) => {
+ canvas.listenerCount--;
+ remove(...args);
+ };
+ return canvas;
+}
+
+const pointer = (type, x, y, extra = {}) =>
+ Object.assign(new Event(type), {
+ clientX: x,
+ clientY: y,
+ button: 0,
+ shiftKey: false,
+ ctrlKey: false,
+ altKey: false,
+ ...extra,
+ });
+
+function setup() {
+ const canvas = fakeCanvas();
+ const cameraOptions = [];
+ const view = {
+ canvas,
+ camera: {
+ set options(value) {
+ cameraOptions.push(value);
+ },
+ },
+ };
+ const { input, destroy } = createNavaraInput(view);
+ return { canvas, input, destroy, cameraOptions };
+}
+
+test('a pinch whose still finger lifts first is not a click', async () => {
+ const { canvas, input, destroy } = setup();
+ let clicks = 0;
+ input.on('click', () => clicks++);
+ const touch = (type, id, x, y) =>
+ pointer(type, x, y, {
+ pointerId: id,
+ pointerType: 'touch',
+ isPrimary: id === 1,
+ });
+ canvas.dispatchEvent(touch('pointerdown', 1, 100, 300));
+ canvas.dispatchEvent(touch('pointerdown', 2, 300, 300));
+ // Only the first finger moves (a one-sided pinch).
+ for (let i = 1; i <= 5; i++)
+ canvas.dispatchEvent(touch('pointermove', 1, 100 - 20 * i, 300));
+ canvas.dispatchEvent(touch('pointerup', 2, 300, 300));
+ canvas.dispatchEvent(touch('pointerup', 1, 0, 300));
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ assert.equal(clicks, 0);
+ canvas.dispatchEvent(touch('pointerdown', 1, 100, 300));
+ canvas.dispatchEvent(touch('pointercancel', 1, 100, 300));
+ canvas.dispatchEvent(touch('pointerup', 1, 100, 300));
+ await Promise.resolve();
+ assert.equal(clicks, 0, 'a cancelled pointer cannot click');
+ canvas.dispatchEvent(touch('pointerdown', 1, 100, 300));
+ canvas.dispatchEvent(touch('pointerup', 1, 100, 300));
+ await Promise.resolve();
+ assert.equal(clicks, 1, 'the next single tap still clicks');
+ destroy();
+});
+
+test('a mouse released off the canvas does not hold back the next tap', async () => {
+ const { canvas, input, destroy } = setup();
+ let clicks = 0;
+ input.on('click', () => clicks++);
+ // The mouse goes down on the globe and up outside it.
+ canvas.dispatchEvent(
+ pointer('pointerdown', 100, 300, { pointerId: 1, isPrimary: true }),
+ );
+ canvas.dispatchEvent(
+ pointer('pointerdown', 100, 300, {
+ pointerId: 7,
+ pointerType: 'touch',
+ isPrimary: true,
+ }),
+ );
+ canvas.dispatchEvent(
+ pointer('pointerup', 100, 300, {
+ pointerId: 7,
+ pointerType: 'touch',
+ isPrimary: true,
+ }),
+ );
+ await Promise.resolve();
+ assert.equal(clicks, 1);
+ destroy();
+});
diff --git a/src/globe/navara/maps.js b/src/globe/navara/maps.js
index 1dd0bb125..b99cfc5c7 100644
--- a/src/globe/navara/maps.js
+++ b/src/globe/navara/maps.js
@@ -13,9 +13,9 @@
// - photoreal: Google 3D tiles with a browser key only (no server proxy),
// with Google's logo and the tiles' own
// copyright lines (creditLayerId) on screen. Showing them hides the base
-// imagery, as MapSourceController removes it; clearing them does not bring
-// it back until setBaseImagery. Hidden, not deleted: deleting the last
-// raster layer stops Navara 0.2.1 loading terrain at the current view.
+// imagery; clearing them restores the last base imagery and its credit.
+// Hidden, not deleted: deleting the last raster layer stops Navara 0.2.1
+// loading terrain at the current view.
import {
SENTINEL2_CREDIT,
@@ -124,6 +124,8 @@ export function createNavaraMaps(
// call made last decides what shows.
const generations = { display: 0, terrain: 0 };
let destroyed = false;
+ // maps.terrainRevision: one more for every terrain shown or cleared.
+ let terrainRevision = 0;
function retire(slot) {
if (!slot) return;
@@ -137,6 +139,7 @@ export function createNavaraMaps(
const slot = slots[name];
slots[name] = null;
retire(slot);
+ if (name === 'terrain' && slot) terrainRevision++;
}
// The new layer goes in before the old one goes: deleting the last raster
@@ -150,6 +153,7 @@ export function createNavaraMaps(
typeof credit === 'function' ? credit(layer) : credit,
);
retire(previous);
+ if (name === 'terrain') terrainRevision++;
if (shown.length) view.attribution?.add(shown);
slots[name] = {
key,
@@ -204,6 +208,7 @@ export function createNavaraMaps(
type: 'quantized-mesh',
url: `${endpoint.url}{z}/{x}/{y}.terrain?v=1.2.0`,
token: endpoint.accessToken,
+ attributions: endpoint.attributions ?? [],
};
}
default:
@@ -249,7 +254,16 @@ export function createNavaraMaps(
return;
}
if (slots.terrain?.key === description.url) return;
- show('terrain', description.url, description, { type: 'terrain' });
+ const { attributions = [], ...sourceDesc } = description;
+ show(
+ 'terrain',
+ description.url,
+ sourceDesc,
+ { type: 'terrain' },
+ attributions.map(({ html }) => ({ attributionHtml: html })),
+ );
+ if (attributions.some(({ collapsible }) => collapsible === false))
+ view.attribution?.show();
},
async setPhotorealTiles(source) {
const generation = ++generations.display;
@@ -329,10 +343,29 @@ export function createNavaraMaps(
};
},
current: () => ({ baseImagery: currentBase, photoreal: tilesSource }),
+ terrainRevision: () => terrainRevision,
};
return {
maps,
+ /**
+ * surface.sampleTerrainMostDetailed: the terrain shown sampled at its
+ * finest level (Navara fetches the tiles); 0 on the ellipsoid.
+ */
+ async sampleTerrain(positions) {
+ const source = slots.terrain?.source;
+ if (!source) return positions.map(() => 0);
+ const sampled = await view.sampleTerrainMostDetailed(
+ source,
+ positions.map((place) => ({
+ lat: (place.latitude * 180) / Math.PI,
+ lng: (place.longitude * 180) / Math.PI,
+ })),
+ );
+ return sampled.map(({ height }) =>
+ Number.isFinite(height) ? height : undefined,
+ );
+ },
destroy() {
if (destroyed) return;
destroyed = true;
diff --git a/src/globe/navara/maps.test.mjs b/src/globe/navara/maps.test.mjs
index 2b8c292af..44a79cc37 100644
--- a/src/globe/navara/maps.test.mjs
+++ b/src/globe/navara/maps.test.mjs
@@ -392,3 +392,49 @@ test('a tile overlay stays above the base imagery across map switches and credit
remove();
destroy();
});
+
+test('world terrain publishes endpoint credits and removes them on replacement', async () => {
+ const view = fakeView();
+ const { maps, destroy } = createNavaraMaps(view, {
+ fetch: async () => ({
+ ok: true,
+ json: async () => ({
+ url: 'https://terrain.example/',
+ accessToken: 'temporary',
+ attributions: [{ html: 'Terrain provider', collapsible: false }],
+ }),
+ }),
+ });
+ await maps.setTerrain({ kind: 'world', accessToken: 'configured' });
+ assert.ok(
+ view.shown.some((credit) => credit.attributionHtml === 'Terrain provider'),
+ );
+ assert.ok(view.opened > 0);
+ await maps.setTerrain({ kind: 'ellipsoid' });
+ assert.deepEqual(view.shown, []);
+ destroy();
+});
+
+test('terrainRevision counts terrain shown or cleared; sampling reads the shown source', async () => {
+ const view = fakeView();
+ const sampled = [];
+ view.sampleTerrainMostDetailed = async (source, positions) => {
+ sampled.push({ source, positions });
+ return positions.map(({ lat, lng }) => ({ lat, lng, height: lat + lng }));
+ };
+ const navara = createNavaraMaps(view);
+ const { maps } = navara;
+ const place = { longitude: Math.PI / 4, latitude: Math.PI / 6, height: 0 };
+ assert.deepEqual(await navara.sampleTerrain([place]), [0], 'the ellipsoid');
+ await maps.setTerrain({ kind: 'keyless' });
+ assert.equal(maps.terrainRevision(), 1);
+ const [height] = await navara.sampleTerrain([place]);
+ assert.ok(Math.abs(height - 75) < 1e-9);
+ assert.equal(sampled[0].source, layersOf(view, 'terrain')[0].desc.source);
+ await maps.setTerrain({ kind: 'keyless' });
+ assert.equal(maps.terrainRevision(), 1, 'the same terrain again');
+ await maps.setTerrain({ kind: 'ellipsoid' });
+ assert.equal(maps.terrainRevision(), 2);
+ await maps.setTerrain({ kind: 'ellipsoid' });
+ assert.equal(maps.terrainRevision(), 2, 'nothing to clear');
+});
diff --git a/src/globe/navara/movers/index.js b/src/globe/navara/movers/index.js
index b1da819fa..daba85972 100644
--- a/src/globe/navara/movers/index.js
+++ b/src/globe/navara/movers/index.js
@@ -130,6 +130,7 @@ export function createNavaraMovers(view, { overlay, aces = false }) {
);
return {
ready: mover.ready,
+ failed: mover.failed,
commit() {
mover.commit(store.dirty());
request();
@@ -168,6 +169,7 @@ export function createNavaraMovers(view, { overlay, aces = false }) {
});
return {
ready: mover.ready,
+ failed: mover.failed,
pickRadiusPx: mover.pickRadiusPx,
commit() {
mover.commit(store.dirty());
diff --git a/src/globe/navara/movers/modelMover.js b/src/globe/navara/movers/modelMover.js
index 5e39f2647..7473b2a81 100644
--- a/src/globe/navara/movers/modelMover.js
+++ b/src/globe/navara/movers/modelMover.js
@@ -199,10 +199,14 @@ export function createModelMover(
};
const group = { meshes: [], pickMeshes: [], ready: null };
+ let failed = false;
const asset = load(model.url).catch(() => {
// A missing asset keeps the layer's glyph fallback. Do not leak the
// rejected loader promise (or its URL) through descriptor creation.
- if (!disposed) console.warn('[globe] Model asset unavailable');
+ if (!disposed) {
+ failed = true;
+ console.warn('[globe] Model asset unavailable');
+ }
return null;
});
group.ready = asset.then((gltf) => {
@@ -259,7 +263,6 @@ export function createModelMover(
overlay.add(tint);
}
materials.push(material);
- if (tint) materials.push(tint.material);
parts.push({ mesh, tint, local: child.matrixWorld.clone() });
group.meshes.push(mesh);
group.pickMeshes.push(tint ?? mesh);
@@ -288,6 +291,7 @@ export function createModelMover(
group,
uniforms,
ready: () => loaded,
+ failed: () => failed,
origin: () => origin,
/**
* The drawn radius (px) of an instance at its last write, or null when
diff --git a/src/globe/navara/movers/modelMover.test.mjs b/src/globe/navara/movers/modelMover.test.mjs
index 287a2888a..3a837b715 100644
--- a/src/globe/navara/movers/modelMover.test.mjs
+++ b/src/globe/navara/movers/modelMover.test.mjs
@@ -204,6 +204,7 @@ test('a failed model load stays not-ready without an unhandled rejection or leak
});
await mover.group.ready;
assert.equal(mover.ready(), false);
+ assert.equal(mover.failed(), true);
assert.equal(mover.group.meshes.length, 0);
assert.deepEqual(warnings, ['[globe] Model asset unavailable']);
mover.dispose();
@@ -278,12 +279,21 @@ test('display tint shares transforms, preserves fade opacity and leaves no overl
shader.fragmentShader,
/mix\(gevLitSrgb, vGevColor.rgb, uGevBlend\)/,
);
+ assert.match(shader.vertexShader, /vGevSonarFactor = gevSonarFactor/);
+ assert.match(shader.fragmentShader, /gevSonarColor\(vec4\(mix/);
assert.equal(base.material.colorWrite, false);
assert.equal(
mover.group.pickMeshes[0],
tint,
'GPU picking keeps its color-writing material',
);
+ store.set(0, { color: [1, 1, 1, 1] });
+ mover.commit(store.dirty());
+ assert.equal(
+ tint.material.transparent,
+ true,
+ 'sonar can fade an opaque model',
+ );
store.setCount(0);
mover.commit(store.dirty());
assert.equal(tint.count, 0);
diff --git a/src/globe/navara/movers/modelTint.js b/src/globe/navara/movers/modelTint.js
index f2c879fd3..3509d78c7 100644
--- a/src/globe/navara/movers/modelTint.js
+++ b/src/globe/navara/movers/modelTint.js
@@ -2,6 +2,7 @@
// the view's HDR effects. This keeps MSAA from spreading HDR tint into edges.
import { InstancedMesh } from 'three';
import { OVERLAY_DEPTH_FRAGMENT } from '../overlayDepth.js';
+import { DETACHED_SONAR, SONAR_VERTEX } from '../sonar.js';
// three's pinned ACESFilmicToneMapping, with the view's public exposure.
const ACES = `
@@ -18,6 +19,7 @@ vec3 gevModelAces(vec3 color) {
export function createModelTint(mesh, uniforms, overlayUniforms) {
const material = mesh.material.clone();
+ material.transparent = true;
material.depthTest = false;
material.depthWrite = false;
material.toneMapped = false;
@@ -25,22 +27,22 @@ export function createModelTint(mesh, uniforms, overlayUniforms) {
? 'gl_FragColor.rgb'
: 'gevModelAces(gl_FragColor.rgb)';
material.onBeforeCompile = (shader) => {
- Object.assign(shader.uniforms, uniforms, overlayUniforms, {
+ Object.assign(shader.uniforms, DETACHED_SONAR, uniforms, overlayUniforms, {
uGevDepthMode: { value: 1 },
});
shader.vertexShader = shader.vertexShader
.replace(
'void main() {',
- 'attribute vec4 gevColor;\nvarying vec4 vGevColor;\nvarying float vGevDepthFree;\nvoid main() {',
+ `attribute vec4 gevColor;\nvarying vec4 vGevColor;\nvarying float vGevDepthFree;\nvarying float vGevSonarFactor;\nuniform vec2 uGevResolution;\n${SONAR_VERTEX}\nvoid main() {`,
)
.replace(
'#include ',
- '#include \nvGevColor = gevColor;\nvGevDepthFree = 0.0;',
+ '#include \nvGevColor = gevColor;\nvGevDepthFree = 0.0;\nvGevSonarFactor = gevSonarFactor(projectionMatrix * modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0), uGevResolution);',
);
shader.fragmentShader = shader.fragmentShader
.replace(
'void main() {',
- `uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\n${ACES}\n${OVERLAY_DEPTH_FRAGMENT}\nvoid main() {`,
+ `uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\n${ACES}\nvarying float vGevSonarFactor;\n${SONAR_VERTEX}\n${OVERLAY_DEPTH_FRAGMENT}\nvoid main() {`,
)
.replace(
'#include ',
@@ -56,7 +58,7 @@ export function createModelTint(mesh, uniforms, overlayUniforms) {
)
.replace(
'#include ',
- `#include \nvec3 gevLitSrgb = sRGBTransferOETF(vec4(${lit}, 1.0)).rgb;\ngl_FragColor.rgb = sRGBTransferEOTF(vec4(mix(gevLitSrgb, vGevColor.rgb, uGevBlend), 1.0)).rgb;`,
+ `#include \nvec3 gevLitSrgb = sRGBTransferOETF(vec4(${lit}, 1.0)).rgb;\nvec4 gevContact = gevSonarColor(vec4(mix(gevLitSrgb, vGevColor.rgb, uGevBlend), gl_FragColor.a), vGevSonarFactor);\ngl_FragColor = vec4(sRGBTransferEOTF(gevContact).rgb, gevContact.a);`,
);
};
material.customProgramCacheKey = () => 'gev-model-lit-display-color';
diff --git a/src/globe/navara/movers/moverMesh.js b/src/globe/navara/movers/moverMesh.js
index e4fc34e9c..cb32f21fb 100644
--- a/src/globe/navara/movers/moverMesh.js
+++ b/src/globe/navara/movers/moverMesh.js
@@ -227,24 +227,28 @@ function cesiumSampling(texture, encoded = false) {
}
/** The atlas as a three texture (a canvas at once, a URL once loaded). */
-function atlasTexture(atlas, encoded, onLoad) {
+function atlasTexture(atlas, encoded, onLoad, onError) {
if (atlas.canvas)
return cesiumSampling(new CanvasTexture(atlas.canvas), encoded);
const texture = cesiumSampling(new Texture(), encoded);
const image = new Image();
image.crossOrigin = 'anonymous';
const release = () => {
- image.onload = null;
+ image.onload = image.onerror = null;
texture.image = null;
texture.removeEventListener('dispose', release);
};
texture.addEventListener('dispose', release);
image.onload = () => {
- image.onload = null;
+ image.onload = image.onerror = null;
texture.image = image;
texture.needsUpdate = true;
onLoad();
};
+ image.onerror = () => {
+ image.onload = image.onerror = null;
+ onError();
+ };
image.src = atlas.url;
return texture;
}
@@ -313,14 +317,22 @@ export function createMoverMesh(
};
// A canvas atlas is there at once; a URL one once its image loads.
let textureReady = point || Boolean(options.sprite.atlas.canvas);
+ let textureFailed = false;
const encodedAtlas = !point && options.sharpen === true;
const material = new MeshBasicMaterial({
map: point
? null
- : atlasTexture(options.sprite.atlas, encodedAtlas, () => {
- textureReady = true;
- onTextureLoad();
- }),
+ : atlasTexture(
+ options.sprite.atlas,
+ encodedAtlas,
+ () => {
+ textureReady = true;
+ onTextureLoad();
+ },
+ () => {
+ textureFailed = true;
+ },
+ ),
transparent: true,
alphaTest: point || encodedAtlas ? 0.005 : 0.02,
// Drawn after tone mapping, with no depth buffer: the shader tests the
@@ -382,6 +394,7 @@ export function createMoverMesh(
mesh,
uniforms,
ready: () => textureReady,
+ failed: () => textureFailed,
origin: () => origin,
/** Writes the changed instances; the origin starts at the first one. */
commit(range) {
diff --git a/src/globe/navara/overlayPass.js b/src/globe/navara/overlayPass.js
index d6be5dc6a..fe8530a64 100644
--- a/src/globe/navara/overlayPass.js
+++ b/src/globe/navara/overlayPass.js
@@ -165,6 +165,17 @@ export class GevOverlayPass extends Pass {
resolution[0],
resolution[1],
);
+ if (signature) {
+ // The sonar's uniforms change the glyphs only while it applies; its
+ // angle moves every frame even then, so it is left out otherwise.
+ const uniforms = this.overlay.uniforms;
+ const enabled = uniforms.uGevSonarEnabled?.value >= 0.5;
+ const [angle, range, floor, active] = uniforms.uGevSonar?.value ?? [];
+ signature.push(enabled);
+ if (enabled) signature.push(floor, active);
+ if (enabled && active >= 0.5)
+ signature.push(angle, range, uniforms.uGevSonarSector?.value);
+ }
const targetWidth = resolution[0];
const targetHeight = resolution[1];
if (
diff --git a/src/globe/navara/overlaySonar.test.mjs b/src/globe/navara/overlaySonar.test.mjs
new file mode 100644
index 000000000..1efd84317
--- /dev/null
+++ b/src/globe/navara/overlaySonar.test.mjs
@@ -0,0 +1,110 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ Scene,
+ PerspectiveCamera,
+ HalfFloatType,
+ NoColorSpace,
+ SRGBColorSpace,
+ InstancedMesh,
+ MeshBasicMaterial,
+ PlaneGeometry,
+ Texture,
+} from 'three';
+import { GevOverlayPass } from './overlayPass.js';
+
+function harness() {
+ let active = false,
+ target = null,
+ clearColor = 0x123456,
+ clearAlpha = 1;
+ const overlay = {
+ displayBlend: true,
+ hasDrawables: () => active,
+ uniforms: {
+ uGevResolution: { value: [0, 0] },
+ uGevDisplayBlend: { value: 0 },
+ uGevSonar: { value: [0, 1, 0.84, 1] },
+ uGevSonarSector: { value: 24 },
+ uGevSonarEnabled: { value: 1 },
+ },
+ };
+ const scene = new Scene(),
+ pass = new GevOverlayPass(scene, new PerspectiveCamera(), overlay),
+ draws = [];
+ const renderer = {
+ domElement: new EventTarget(),
+ autoClear: true,
+ outputColorSpace: SRGBColorSpace,
+ getPixelRatio: () => 1,
+ getClearColor(color) {
+ return color.set(clearColor);
+ },
+ getClearAlpha() {
+ return clearAlpha;
+ },
+ setClearColor(color, alpha) {
+ clearColor = color.isColor ? color.getHex() : color;
+ clearAlpha = alpha;
+ },
+ clear() {},
+ get clearAlpha() {
+ return clearAlpha;
+ },
+ setRenderTarget(value) {
+ target = value;
+ },
+ render(object) {
+ draws.push({
+ object,
+ target,
+ displayBlend: overlay.uniforms.uGevDisplayBlend.value,
+ });
+ },
+ };
+ return {
+ overlay,
+ scene,
+ pass,
+ draws,
+ renderer,
+ setActive(value) {
+ active = value;
+ },
+ get target() {
+ return target;
+ },
+ };
+}
+test('static sprite glyphs are drawn again when the Cyber sonar moves', () => {
+ const h = harness(),
+ input = { width: 1280, height: 800, texture: {} },
+ output = {},
+ mesh = new InstancedMesh(
+ new PlaneGeometry(1, 1),
+ new MeshBasicMaterial({ map: new Texture() }),
+ 1,
+ );
+ mesh.userData.gevStaticSprite = true;
+ mesh.count = 1;
+ h.scene.add(mesh);
+ h.setActive(true);
+ h.pass.render(h.renderer, input, output);
+ const drawn = () => h.draws.filter((draw) => draw.object === h.scene).length;
+ assert.equal(drawn(), 1);
+ // The sweep turns (sonar.js writes the shared uniform each frame).
+ h.overlay.uniforms.uGevSonar.value[0] = 90;
+ h.pass.render(h.renderer, input, output);
+ assert.equal(drawn(), 2, 'a new sonar angle draws the glyphs again');
+ h.overlay.uniforms.uGevSonarSector.value = 60;
+ h.pass.render(h.renderer, input, output);
+ assert.equal(drawn(), 3, 'a new sector draws the glyphs again');
+ // The Cyber treatment is turned off.
+ h.overlay.uniforms.uGevSonarEnabled.value = 0;
+ h.pass.render(h.renderer, input, output);
+ assert.equal(drawn(), 4, 'sonar off draws the glyphs again');
+ // Off, the angle still moves every frame; the glyphs stay cached.
+ h.overlay.uniforms.uGevSonar.value[0] = 180;
+ h.pass.render(h.renderer, input, output);
+ assert.equal(drawn(), 4, 'the angle of an unused sonar draws nothing');
+});
diff --git a/src/globe/navara/sonar.js b/src/globe/navara/sonar.js
index d9b7e4440..bf5169260 100644
--- a/src/globe/navara/sonar.js
+++ b/src/globe/navara/sonar.js
@@ -9,7 +9,7 @@
// colour pulled toward the scanner tint by that factor and its alpha scaled
// by it.
// Positions, picks and the instance data stay as the layers set them.
-// Models keep their colours (Cesium tints them on the CPU).
+// Models apply the same treatment after lighting (movers/modelTint.js).
const views = new WeakMap();
diff --git a/src/globe/navara/surface.js b/src/globe/navara/surface.js
index 02f804494..1970b3e1b 100644
--- a/src/globe/navara/surface.js
+++ b/src/globe/navara/surface.js
@@ -234,9 +234,15 @@ const tileSizeOf = (root) => {
* isIdle: () => boolean,
* retainTileGeometry: () => void,
* }} hooks Navara internals (internals.js).
+ * @param {(positions: object[]) => Promise<(number | undefined)[]>} [sampleTerrain]
+ * The maps' sampling of the terrain shown (maps.js).
* @returns {{ surface: import('../contract.js').GlobeSurface }}
*/
-export function createNavaraSurface(view, hooks) {
+export function createNavaraSurface(
+ view,
+ hooks,
+ sampleTerrain = async (positions) => positions.map(() => 0),
+) {
let supported = true;
try {
hooks.retainTileGeometry();
@@ -342,6 +348,7 @@ export function createNavaraSurface(view, hooks) {
supported,
terrainHeight,
terrainHeightSupported: true,
+ sampleTerrainMostDetailed: (positions) => sampleTerrain(positions),
clampToHeight(position, exclude) {
const place = cartographic.fromCartesian(position, WGS84);
if (!place) return undefined;
diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs
index 69ba2c4d3..6599a8b16 100644
--- a/src/globe/testing/contractSuite.mjs
+++ b/src/globe/testing/contractSuite.mjs
@@ -1490,6 +1490,31 @@ export function createContractSuite(
ensure(outcome.join() === 'cancel', `flight ended with ${outcome}`);
},
],
+ [
+ 'camera.setView: cancels an active flight and retains its destination',
+ async () => {
+ await nadirView();
+ const outcome = [];
+ camera.flyTo({
+ destination: cartesian3.fromDegrees(100, 0, 5e6),
+ duration: 5,
+ complete: () => outcome.push('complete'),
+ cancel: () => outcome.push('cancel'),
+ });
+ await settle(2);
+ const destination = cartesian3.fromDegrees(20, 10, 200000);
+ camera.setView({ destination });
+ await settle(3);
+ ensure(outcome.join() === 'cancel', `flight ended with ${outcome}`);
+ ensure(!camera.flightActive(), 'setView left the flight active');
+ near(
+ cartesian3.distance(camera.pose().position, destination),
+ 0,
+ 0.01,
+ 'setView position error (m)',
+ );
+ },
+ ],
[
'camera.flyTo: a newer flight cancels the previous one',
async () => {
diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js
index f4c698251..76432aea7 100644
--- a/src/globe/testing/fakeEngine.js
+++ b/src/globe/testing/fakeEngine.js
@@ -77,9 +77,11 @@ const SCRIPTABLE = new Set([
'sampleHeight',
'sampleHeightDetailed',
'terrainHeight',
+ 'sampleTerrainMostDetailed',
'clampToHeight',
'tilesLoaded',
'worldToWindow',
+ 'moverFailed',
]);
const EASINGS = {
linear: (t) => t,
@@ -254,13 +256,16 @@ export function createFakeEngine(options = {}) {
};
});
// Movers keep their store for tests; commit counts as a render request.
- const moverRegistry = createMoverRegistry(() => {
+ const moverRegistry = createMoverRegistry((options) => {
const part = {
commits: 0,
commit() {
part.commits++;
fake.renderRequests++;
},
+ // Scripted as 'moverFailed' (set id, index) for a load that fails.
+ failed: (index) =>
+ scripted('moverFailed', [options.id, index], () => false),
destroy() {},
};
return part;
@@ -340,7 +345,14 @@ export function createFakeEngine(options = {}) {
renderRequests: 0,
cameraInputsEnabled: true,
credits: [],
- maps: { imagery: undefined, terrain: undefined, photoreal: null },
+ maps: {
+ imagery: undefined,
+ terrain: undefined,
+ photoreal: null,
+ terrainRevision: 0,
+ },
+ // render.maxTextureSize: a common WebGL2 limit; tests set a smaller one.
+ maxTextureSize: 4096,
destroyed: false,
};
const record = (method, args) => fake.calls.push({ method, args });
@@ -523,6 +535,7 @@ export function createFakeEngine(options = {}) {
command('camera.setView', [view]);
if (!view?.destination)
throw new TypeError('setView needs a destination');
+ cancelCurrentFlight();
apply(viewFor(view.destination, view.orientation, CESIUM_DEFAULTS));
},
flyTo(flightOptions) {
@@ -780,6 +793,7 @@ export function createFakeEngine(options = {}) {
continuous = Boolean(enabled);
},
isContinuous: () => continuous,
+ maxTextureSize: () => fake.maxTextureSize,
setSharpen(options) {
record('render.setSharpen', [options]);
sharpen = mergeSharpen(sharpen, options);
@@ -823,6 +837,14 @@ export function createFakeEngine(options = {}) {
return scripted('terrainHeight', [position], () => 0);
},
terrainHeightSupported: true,
+ async sampleTerrainMostDetailed(positions) {
+ record('surface.sampleTerrainMostDetailed', [positions]);
+ return scripted('sampleTerrainMostDetailed', [positions], () =>
+ positions.map((position) =>
+ scripted('terrainHeight', [position], () => 0),
+ ),
+ );
+ },
clampToHeight(position, exclude) {
record('surface.clampToHeight', [position, exclude]);
return scripted('clampToHeight', [position, exclude], () => undefined);
@@ -894,6 +916,7 @@ export function createFakeEngine(options = {}) {
)
throw new TypeError(`Unknown terrain source: ${kind}`);
fake.maps.terrain = source;
+ fake.maps.terrainRevision++;
},
async setPhotorealTiles(source) {
record('maps.setPhotorealTiles', [source]);
@@ -914,6 +937,7 @@ export function createFakeEngine(options = {}) {
baseImagery: fake.maps.imagery ?? null,
photoreal: fake.maps.photoreal,
}),
+ terrainRevision: () => fake.maps.terrainRevision,
},
destroy() {
record('destroy', []);
diff --git a/src/hud.js b/src/hud.js
index 5d0091ba3..789ae78ac 100644
--- a/src/hud.js
+++ b/src/hud.js
@@ -561,7 +561,7 @@ export class IntelHUD {
// i18n-ignore: sensor codes
readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
if (this.engine?.host.container?.hasAttribute?.('data-terrain-daylight'))
- readouts.alt += ' · DAYLIGHT VIEW';
+ readouts.alt += ` · ${t('DAYLIGHT VIEW')}`;
// Collection timestamp
const now = new Date();
diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json
index f0d5f49d9..d06b1e649 100644
--- a/src/i18n/locales/ar.json
+++ b/src/i18n/locales/ar.json
@@ -3471,5 +3471,12 @@
"Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة",
"event age filter|All": "الكل",
"Event age": "عمر الحدث",
- "Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ"
+ "Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ",
+ "Data layers: {layers}": "طبقات البيانات: {layers}",
+ "No registered data layers": "لا توجد طبقات بيانات مسجّلة",
+ "DAYLIGHT VIEW": "عرض نهاري",
+ "Globe engine failed to start. Running {engine}: {message}": "تعذّر تشغيل محرك الكرة الأرضية. يعمل الآن {engine}: {message}",
+ "Bing imagery is not available on the experimental Navara engine": "صور Bing غير متاحة في محرك Navara التجريبي",
+ "NOT ON THIS ENGINE": "غير متاح في هذا المحرك",
+ "Kept in the link: {layers}": "محفوظة في الرابط: {layers}"
}
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index 3a017195c..873a808cb 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -3171,5 +3171,12 @@
"Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben",
"event age filter|All": "Alle",
"Event age": "Ereignisalter",
- "Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben"
+ "Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben",
+ "Data layers: {layers}": "Datenebenen: {layers}",
+ "No registered data layers": "Keine registrierten Datenebenen",
+ "DAYLIGHT VIEW": "TAGESLICHTANSICHT",
+ "Globe engine failed to start. Running {engine}: {message}": "Die Globus-Engine konnte nicht starten. Es läuft {engine}: {message}",
+ "Bing imagery is not available on the experimental Navara engine": "Bing-Bildmaterial ist in der experimentellen Navara-Engine nicht verfügbar",
+ "NOT ON THIS ENGINE": "NICHT IN DIESER ENGINE",
+ "Kept in the link: {layers}": "Im Link beibehalten: {layers}"
}
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index ecc5fb698..239cce022 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -3246,5 +3246,12 @@
"Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha",
"event age filter|All": "Todos",
"Event age": "Antigüedad",
- "Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha"
+ "Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha",
+ "Data layers: {layers}": "Capas de datos: {layers}",
+ "No registered data layers": "No hay capas de datos registradas",
+ "DAYLIGHT VIEW": "VISTA DIURNA",
+ "Globe engine failed to start. Running {engine}: {message}": "No se pudo iniciar el motor del globo. Se usa {engine}: {message}",
+ "Bing imagery is not available on the experimental Navara engine": "Las imágenes de Bing no están disponibles en el motor experimental Navara",
+ "NOT ON THIS ENGINE": "NO EN ESTE MOTOR",
+ "Kept in the link: {layers}": "Se conservan en el enlace: {layers}"
}
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 490c2acd3..36256617f 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -3246,5 +3246,12 @@
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
"event age filter|All": "Tous",
"Event age": "Ancienneté",
- "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés"
+ "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés",
+ "Data layers: {layers}": "Couches de données : {layers}",
+ "No registered data layers": "Aucune couche de données enregistrée",
+ "DAYLIGHT VIEW": "VUE DE JOUR",
+ "Globe engine failed to start. Running {engine}: {message}": "Le moteur du globe n’a pas pu démarrer. {engine} est utilisé : {message}",
+ "Bing imagery is not available on the experimental Navara engine": "L’imagerie Bing n’est pas disponible avec le moteur expérimental Navara",
+ "NOT ON THIS ENGINE": "PAS SUR CE MOTEUR",
+ "Kept in the link: {layers}": "Conservées dans le lien : {layers}"
}
diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json
index 428b89bd0..c986bbc44 100644
--- a/src/i18n/locales/it.json
+++ b/src/i18n/locales/it.json
@@ -3246,5 +3246,12 @@
"Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti",
"event age filter|All": "Tutti",
"Event age": "Età degli eventi",
- "Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti"
+ "Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti",
+ "Data layers: {layers}": "Livelli dati: {layers}",
+ "No registered data layers": "Nessun livello dati registrato",
+ "DAYLIGHT VIEW": "VISTA DIURNA",
+ "Globe engine failed to start. Running {engine}: {message}": "Impossibile avviare il motore del globo. È in uso {engine}: {message}",
+ "Bing imagery is not available on the experimental Navara engine": "Le immagini Bing non sono disponibili nel motore sperimentale Navara",
+ "NOT ON THIS ENGINE": "NON IN QUESTO MOTORE",
+ "Kept in the link: {layers}": "Conservati nel link: {layers}"
}
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 9d9ca24e5..4657ac0ef 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -3096,5 +3096,12 @@
"Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持",
"event age filter|All": "すべて",
"Event age": "経過日数",
- "Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持"
+ "Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持",
+ "Data layers: {layers}": "データレイヤー:{layers}",
+ "No registered data layers": "登録済みのデータレイヤーはありません",
+ "DAYLIGHT VIEW": "昼光ビュー",
+ "Globe engine failed to start. Running {engine}: {message}": "地球儀エンジンを起動できませんでした。{engine} で実行中:{message}",
+ "Bing imagery is not available on the experimental Navara engine": "Bing画像は実験版 Navara エンジンでは利用できません",
+ "NOT ON THIS ENGINE": "このエンジンでは非対応",
+ "Kept in the link: {layers}": "リンクに保持:{layers}"
}
diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json
index 8764f136b..43bf90e36 100644
--- a/src/i18n/locales/pt.json
+++ b/src/i18n/locales/pt.json
@@ -3246,5 +3246,12 @@
"Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos",
"event age filter|All": "Todos",
"Event age": "Idade do evento",
- "Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos"
+ "Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos",
+ "Data layers: {layers}": "Camadas de dados: {layers}",
+ "No registered data layers": "Nenhuma camada de dados registrada",
+ "DAYLIGHT VIEW": "VISTA DIURNA",
+ "Globe engine failed to start. Running {engine}: {message}": "Não foi possível iniciar o motor do globo. Usando {engine}: {message}",
+ "Bing imagery is not available on the experimental Navara engine": "As imagens do Bing não estão disponíveis no motor experimental Navara",
+ "NOT ON THIS ENGINE": "NÃO NESTE MOTOR",
+ "Kept in the link: {layers}": "Mantidas no link: {layers}"
}
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index eab70a251..ea48ce635 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -3321,5 +3321,12 @@
"Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены",
"event age filter|All": "Все",
"Event age": "Возраст события",
- "Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены"
+ "Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены",
+ "Data layers: {layers}": "Слои данных: {layers}",
+ "No registered data layers": "Нет зарегистрированных слоёв данных",
+ "DAYLIGHT VIEW": "ДНЕВНОЙ ВИД",
+ "Globe engine failed to start. Running {engine}: {message}": "Не удалось запустить движок глобуса. Работает {engine}: {message}",
+ "Bing imagery is not available on the experimental Navara engine": "Снимки Bing недоступны в экспериментальном движке Navara",
+ "NOT ON THIS ENGINE": "НЕТ В ЭТОМ ДВИЖКЕ",
+ "Kept in the link: {layers}": "Сохранены в ссылке: {layers}"
}
diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json
index 4c40f7fe0..7dde7ec4b 100644
--- a/src/i18n/locales/uk.json
+++ b/src/i18n/locales/uk.json
@@ -3321,5 +3321,12 @@
"Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено",
"event age filter|All": "Усі",
"Event age": "Вік події",
- "Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено"
+ "Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено",
+ "Data layers: {layers}": "Шари даних: {layers}",
+ "No registered data layers": "Немає зареєстрованих шарів даних",
+ "DAYLIGHT VIEW": "ДЕННИЙ ВИГЛЯД",
+ "Globe engine failed to start. Running {engine}: {message}": "Не вдалося запустити рушій глобуса. Працює {engine}: {message}",
+ "Bing imagery is not available on the experimental Navara engine": "Знімки Bing недоступні в експериментальному рушії Navara",
+ "NOT ON THIS ENGINE": "НЕМАЄ В ЦЬОМУ РУШІЇ",
+ "Kept in the link: {layers}": "Збережено в посиланні: {layers}"
}
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index 5ac570480..b6f49a375 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -3096,5 +3096,12 @@
"Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记",
"event age filter|All": "全部",
"Event age": "事件时间",
- "Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记"
+ "Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记",
+ "Data layers: {layers}": "数据图层:{layers}",
+ "No registered data layers": "没有已注册的数据图层",
+ "DAYLIGHT VIEW": "日光视图",
+ "Globe engine failed to start. Running {engine}: {message}": "地球渲染引擎启动失败。当前运行 {engine}:{message}",
+ "Bing imagery is not available on the experimental Navara engine": "实验性 Navara 引擎不支持 Bing 影像",
+ "NOT ON THIS ENGINE": "此引擎不支持",
+ "Kept in the link: {layers}": "已保留在链接中:{layers}"
}
diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json
index 43fc20020..a6e70fa6c 100644
--- a/src/i18n/locales/zh-Hant.json
+++ b/src/i18n/locales/zh-Hant.json
@@ -3096,5 +3096,12 @@
"Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記",
"event age filter|All": "全部",
"Event age": "事件時間",
- "Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記"
+ "Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記",
+ "Data layers: {layers}": "數據圖層:{layers}",
+ "No registered data layers": "沒有已註冊的數據圖層",
+ "DAYLIGHT VIEW": "日光視圖",
+ "Globe engine failed to start. Running {engine}: {message}": "地球渲染引擎啟動失敗。當前運行 {engine}:{message}",
+ "Bing imagery is not available on the experimental Navara engine": "實驗性 Navara 引擎不支持 Bing 影像",
+ "NOT ON THIS ENGINE": "此引擎不支持",
+ "Kept in the link: {layers}": "已保留在鏈接中:{layers}"
}
diff --git a/src/layers/aircraft/render/cockpitContactDot.test.mjs b/src/layers/aircraft/render/cockpitContactDot.test.mjs
new file mode 100644
index 000000000..fbb46da88
--- /dev/null
+++ b/src/layers/aircraft/render/cockpitContactDot.test.mjs
@@ -0,0 +1,68 @@
+// In the cockpit, far contacts switch their sprite to cockpitContactDotImage()
+// (src/layers/flights/rendering.js, military/rendering.js). The sprite must
+// then draw the dot, not keep the aircraft silhouette cell it had before.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as geo from '../../../geo/index.js';
+import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
+import { aircraftIcon } from '../../../data/aircraftIcons.js';
+import { cockpitContactDotImage } from '../../../data/cockpitContactDot.js';
+import { aircraftAtlasLayout, createAircraftDisplay } from './display.js';
+
+// cockpitContactDotImage() draws on a canvas; a stub document gives it a stable data URL.
+globalThis.document ??= {
+ createElement: () => ({
+ getContext: () => new Proxy({}, { get: () => () => {}, set: () => true }),
+ toDataURL: () => 'data:image/png;base64,COCKPITDOT',
+ }),
+};
+
+const layout = aircraftAtlasLayout();
+const drawAtlas = async () => ({
+ canvas: { width: layout.width, height: layout.height },
+ cells: layout.cells,
+});
+const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+test('a cockpit contact draws the contact dot, not the aircraft silhouette', async () => {
+ const engine = createFakeEngine();
+ const display = createAircraftDisplay(engine, {
+ layerId: 'flights',
+ order: 8,
+ headingOffsetDeg: 180,
+ drawAtlas,
+ });
+ await display.whenReady();
+ display.fleet.attach();
+ const sprite = display.fleet.add({
+ position: geo.cartesian3.fromDegrees(-97.7, 30.2, 3000),
+ image: aircraftIcon('helicopter'),
+ width: 20,
+ height: 20,
+ scale: 1,
+ rotation: 0,
+ color: { red: 1, green: 1, blue: 1, alpha: 1 },
+ id: 'abc123',
+ show: true,
+ });
+ await settle();
+ const cellOf = () => {
+ const { store } = engine.fake.moverSet(sprite.gevBank.set.id);
+ return store.cellKeys[store.cells[0]];
+ };
+ assert.equal(cellOf(), 'helicopter@64');
+ sprite.image = cockpitContactDotImage();
+ sprite.width = 8;
+ sprite.height = 8;
+ engine.fake.frames(2);
+ await settle();
+ assert.equal(cellOf(), 'cockpit-dot');
+ assert.equal(
+ engine.fake.moverSet(sprite.gevBank.set.id).options.sprite.atlas.url,
+ cockpitContactDotImage(),
+ );
+ sprite.image = aircraftIcon('helicopter');
+ assert.equal(cellOf(), 'helicopter@64');
+ display.destroy();
+ engine.destroy();
+});
diff --git a/src/layers/aircraft/render/contract.js b/src/layers/aircraft/render/contract.js
index 4505f9236..7532d902e 100644
--- a/src/layers/aircraft/render/contract.js
+++ b/src/layers/aircraft/render/contract.js
@@ -18,7 +18,9 @@
// scaleByDistance as the engine's own copy). The logic may keep its own
// bookkeeping fields on a handle (prefixed _gev).
// ModelHandle (models.load*) — one glTF instance:
-// show, ready (read-only: placed, and its model loaded), scale,
+// show, ready (read-only: placed, and its model loaded), failed
+// (read-only: its model would not load; a tracked one is loaded again
+// after models.remove), scale,
// computedScale (read-only: scale after minimumPixelSize), color, id,
// modelMatrix (16 numbers, column-major, the model frame: written by
// models.setPose and read for anchors).
@@ -61,7 +63,7 @@
* setVisible(bool), add(spec) → SpriteHandle, remove(sprite),
* restoreOrder(), restoreOrderOnEnable().
* @property {object} models live(), setVisible(bool), loadFleet(spec),
- * loadTracked(spec), preload(url) → Promise, add(model),
+ * loadTracked(spec) → Promise, add(model),
* remove(model), destroy(model), setPose(model, position, headingDeg,
* offsetDeg?) (offsetDeg: the model's nose offset, default the layer's).
* spec: { url, minimumPixelSize, scale, radiusM, color, blendAmount,
@@ -71,7 +73,7 @@
* billboardSize(handle) → { width, height, scale, scaleByDistance } | null.
* spec: { image, width, height, scale, scaleByDistance,
* disableDepthTestDistance, position(), color() → colour, rotation() }.
- * @property {object} follow start(handle) → stop, refocus(handle) → stop,
+ * @property {object} follow start(handle), refocus(handle),
* release(), isFollowing(handle), current() (the tracked handle, or what
* another layer follows), onChanged(listener) → remove.
* @property {object} trails create({ color, width }) → Trail,
diff --git a/src/layers/aircraft/render/display.js b/src/layers/aircraft/render/display.js
index 38312657b..bf03bd4da 100644
--- a/src/layers/aircraft/render/display.js
+++ b/src/layers/aircraft/render/display.js
@@ -22,6 +22,7 @@ import {
aircraftIconCells,
drawAircraftIconAtlas,
} from '../../../data/aircraftIcons.js';
+import { cockpitContactDotImage } from '../../../data/cockpitContactDot.js';
import { createEngineLayerView } from '../../shared/render/engineView.js';
import { Rgba, rgbaFromCss } from '../../shared/color.js';
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
@@ -114,6 +115,10 @@ export function createAircraftDisplay(
const removers = [];
const iconCells = aircraftIconCells();
const defaultIcon = [...iconCells.keys()][0];
+ const cellFor = (image) =>
+ image && image === cockpitContactDotImage._dataUrl
+ ? 'cockpit-dot'
+ : iconCells.get(image);
// --- commits: once per frame listener, or in a microtask -----------------
const pending = new Set();
@@ -172,7 +177,8 @@ export function createAircraftDisplay(
if (!atlas) return null;
const { tracked } = sprite;
const depth = depthKey(sprite.disableDepthTestDistance);
- const key = `${tracked ? 'tracked' : 'fleet'}|${scalarKey(sprite.scaleByDistance)}|${JSON.stringify(depth)}`;
+ const dot = sprite.cell === 'cockpit-dot';
+ const key = `${tracked ? 'tracked' : 'fleet'}|${scalarKey(sprite.scaleByDistance)}|${JSON.stringify(depth)}|${dot}`;
let bank = spriteBanks.get(key);
if (!bank) {
const scalar = sprite.scaleByDistance;
@@ -187,7 +193,9 @@ export function createAircraftDisplay(
depthTest: depth,
pickable: true,
sprite: {
- atlas,
+ atlas: dot
+ ? { url: sprite.image, cells: { 'cockpit-dot': [0, 0, 1, 1] } }
+ : atlas,
sizePx: BASE_SIZE_PX,
scaleByDistance: scalar
? [scalar.near, scalar.nearValue, scalar.far, scalar.farValue]
@@ -246,7 +254,7 @@ export function createAircraftDisplay(
spec.color?.alpha ?? 1,
);
this._image = spec.image;
- this.cell = iconCells.get(spec.image) ?? iconCells.get(defaultIcon);
+ this.cell = cellFor(spec.image) ?? iconCells.get(defaultIcon);
this._scaleByDistance = spec.scaleByDistance
? { ...spec.scaleByDistance }
: undefined;
@@ -369,10 +377,12 @@ export function createAircraftDisplay(
set image(value) {
this._image = value;
- const cell = iconCells.get(value);
+ const cell = cellFor(value);
if (!cell || cell === this.cell) return;
+ const rebank = (cell === 'cockpit-dot') !== (this.cell === 'cockpit-dot');
this.cell = cell;
- this.write({ cell });
+ if (rebank) placeSprite(this);
+ else this.write({ cell });
}
get scaleByDistance() {
@@ -515,6 +525,11 @@ export function createAircraftDisplay(
return this.placed && Boolean(this.gevBank?.set?.ready(this.gevIndex));
}
+ /** The engine could not load what this instance draws. */
+ get failed() {
+ return Boolean(this.gevBank?.set?.failed(this.gevIndex));
+ }
+
/** The scale drawn: minimumPixelSize kept on the CPU. */
get computedScale() {
const minimum = this.spec.minimumPixelSize ?? 0;
@@ -581,6 +596,15 @@ export function createAircraftDisplay(
}
}
+ // A set does not load a failed glTF again: the tracked model's retry
+ // loads it in a new one.
+ const dropModel = (model) => {
+ const bank = model?.gevBank;
+ const retry = model?.tracked && model.failed;
+ bank?.remove(model);
+ if (retry) bank.rebuild();
+ };
+
const models = {
live: () => attached && !destroyed,
setVisible(visible) {
@@ -588,18 +612,13 @@ export function createAircraftDisplay(
for (const bank of modelBanks.values()) bank.setVisible(visible);
},
loadFleet: (spec) => Promise.resolve(new ModelHandle(spec)),
- loadTracked: (spec) => Promise.resolve(new ModelHandle(spec)),
- // The engine loads each glTF itself; nothing to warm.
- preload: (url) => Promise.resolve({ url, destroy() {} }),
+ loadTracked: (spec) =>
+ Promise.resolve(Object.assign(new ModelHandle(spec), { tracked: true })),
add(model) {
modelBankFor(model).add(model);
},
- remove(model) {
- model?.gevBank?.remove(model);
- },
- destroy(model) {
- model?.gevBank?.remove(model);
- },
+ remove: dropModel,
+ destroy: dropModel,
/** The heading-pitch-roll frame (modelMatrix, the model frame); the
* instance turned to the glTF's own axes (GLTF_AXIS). `offsetDeg`: this
* model's nose offset when the layer's models differ (default: the
@@ -753,12 +772,9 @@ export function createAircraftDisplay(
}
handle.gevFollowHandle = followHandle;
FOLLOWED_TRACKED.set(followHandle, handle);
- // The engine's follow frames itself (Cesium's one exact tracked frame
- // included) and ends with it: nothing for the caller to stop.
- return () => {};
},
refocus(handle) {
- return follow.start(handle);
+ follow.start(handle);
},
release() {
engine.camera.following()?.stop();
diff --git a/src/layers/aircraft/render/display.test.mjs b/src/layers/aircraft/render/display.test.mjs
index d91470a7a..b57492d7e 100644
--- a/src/layers/aircraft/render/display.test.mjs
+++ b/src/layers/aircraft/render/display.test.mjs
@@ -316,8 +316,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(read().x, state.position.x, 'the position, per frame');
assert.equal(read().alpha, 0, 'the colour, per frame');
- const stop = display.follow.start(handle);
- assert.equal(typeof stop, 'function');
+ display.follow.start(handle);
const following = engine.camera.following();
assert.equal(following, handle.gevFollowHandle);
assert.equal(following.native, handle, 'cockpit follows the glyph');
diff --git a/src/layers/aircraft/sceneBinding.js b/src/layers/aircraft/sceneBinding.js
index 2323ff6ad..07e967a41 100644
--- a/src/layers/aircraft/sceneBinding.js
+++ b/src/layers/aircraft/sceneBinding.js
@@ -5,7 +5,6 @@ export function createAircraftSceneBinding({
flightState,
services,
parts,
- resolveModel,
enable,
}) {
let resumeFollow = false;
@@ -39,25 +38,6 @@ export function createAircraftSceneBinding({
flightState._renderer = renderer;
try {
renderer.fleet.attach();
- // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed
- // (keeps the retained instance referenced; never rendered). Captured against this epoch so a
- // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle.
- if (!flightState._preloadModel) {
- const epoch = flightState._modelEpoch;
- renderer.models
- .preload(resolveModel())
- .then((m) => {
- if (epoch === flightState._modelEpoch) {
- flightState._preloadModel = m;
- flightState._planeModelLoaded = true;
- } else {
- renderer.models.destroy(m);
- }
- })
- .catch(() => {
- /* tracked plane just stays a billboard a beat longer */
- });
- }
parts.snapshots.presentRecords();
if (flightState._trackedIcao) {
parts.tracking._trackFlight(flightState._trackedIcao, {
@@ -87,8 +67,6 @@ export function createAircraftSceneBinding({
id,
flightState._renderer?.engine,
);
- flightState._trackedCameraFrameStop?.();
- flightState._trackedCameraFrameStop = null;
if (flightState._trackedEntity) {
resumeOrigin =
flightState._trackedEntity.gevSelectionOrigin ?? 'programmatic';
@@ -102,14 +80,9 @@ export function createAircraftSceneBinding({
parts.rendering._releaseModels();
// Removing the model collection destroys it and its models.
renderer.fleet.detach(viewer);
- flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy)
+ flightState._modelEpoch += 1; // Ignore loads from the detached scene.
flightState._modelPending.clear();
flightState._modelGen.clear();
- if (flightState._preloadModel) {
- renderer.models.destroy(flightState._preloadModel);
- flightState._preloadModel = null;
- }
- flightState._planeModelLoaded = false;
flightState._billboards.clear();
flightState._cullPositions.clear();
flightState._detectionObjects.clear();
diff --git a/src/layers/aircraft/sceneBinding.test.mjs b/src/layers/aircraft/sceneBinding.test.mjs
index 9fcca7dd3..0745ad75c 100644
--- a/src/layers/aircraft/sceneBinding.test.mjs
+++ b/src/layers/aircraft/sceneBinding.test.mjs
@@ -81,17 +81,14 @@ function binding(flightState, tracked) {
test('resuming an aircraft on Cesium preserves the pending share navigation', (t) => {
const shell = observedShell(t);
let restored;
- binding(
- { initialized: true, _trackedIcao: 'a00000', _preloadModel: {} },
- (id, options) => {
- restored = { id, ...options };
- follow(
- shell.followChanged,
- { gevSelectionOrigin: options.origin },
- options.resume,
- );
- },
- ).attachRenderer({});
+ binding({ initialized: true, _trackedIcao: 'a00000' }, (id, options) => {
+ restored = { id, ...options };
+ follow(
+ shell.followChanged,
+ { gevSelectionOrigin: options.origin },
+ options.resume,
+ );
+ }).attachRenderer({});
assert.equal(restored.id, 'a00000');
assert.equal(restored.resume, true);
assert.equal(
@@ -126,7 +123,6 @@ test('an engine switch resumes the aircraft with the origin it was selected by',
const flightState = {
initialized: true,
_trackedIcao: 'a00000',
- _preloadModel: null,
_modelEpoch: 0,
_modelPending: new Map(),
_modelGen: new Map(),
@@ -136,12 +132,10 @@ test('an engine switch resumes the aircraft with the origin it was selected by',
_groundSnap: new Map(),
};
const scene = binding(flightState, (id, options) => calls.push(options));
- flightState._preloadModel = {};
scene.attachRenderer({});
assert.equal(calls[0].origin, 'programmatic');
flightState._trackedEntity = { gevSelectionOrigin: 'voice' };
scene.detachRenderer({});
- flightState._preloadModel = {};
scene.attachRenderer({});
assert.equal(calls[1].origin, 'voice');
assert.equal(calls[1].resume, true);
diff --git a/src/layers/aircraft/snapshotRestore.test.mjs b/src/layers/aircraft/snapshotRestore.test.mjs
new file mode 100644
index 000000000..a42cd2c16
--- /dev/null
+++ b/src/layers/aircraft/snapshotRestore.test.mjs
@@ -0,0 +1,45 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as geo from '../../geo/index.js';
+import { createFlightSnapshotRenderer } from '../flights/snapshotRenderer.js';
+import { createMilitarySnapshotRenderer } from '../military/snapshotRenderer.js';
+
+for (const create of [
+ createFlightSnapshotRenderer,
+ createMilitarySnapshotRenderer,
+]) {
+ test(`${create.name} restores ground contact horizon positions`, () => {
+ const records = {
+ data: new Map([
+ [
+ 'abc123',
+ { rawLon: 0, rawLat: 0, renderAltitudeM: -30, klass: 'airliner' },
+ ],
+ ]),
+ };
+ const flightState = {
+ records,
+ _billboards: new Map(),
+ _cullPositions: new Map(),
+ _renderer: { fleet: { add: (spec) => spec } },
+ };
+ const rendering = new Proxy({}, { get: () => () => ({}) });
+ const snapshots = create({
+ flightState,
+ records,
+ groundFloor: {},
+ meshFloor: {},
+ militaryRegistry: {},
+ rendering,
+ tracking: {},
+ motion: { _deadReckon: () => null },
+ queries: {},
+ });
+ snapshots.presentRecords();
+ const position = flightState._cullPositions.get('abc123');
+ assert.ok(position);
+ assert.ok(
+ Math.abs(geo.cartographic.fromCartesian(position).height - 12) < 0.01,
+ );
+ });
+}
diff --git a/src/layers/aircraft/trackedModelFailure.test.mjs b/src/layers/aircraft/trackedModelFailure.test.mjs
new file mode 100644
index 000000000..e1c1ffd7a
--- /dev/null
+++ b/src/layers/aircraft/trackedModelFailure.test.mjs
@@ -0,0 +1,183 @@
+// A tracked aircraft's glTF that the engine fails to load: the glyph stays
+// the visual, the load is tried again after the backoff in a new mover set
+// (a set does not load a failed glTF again), and a contact gives up after its
+// failure budget.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as Cesium from 'cesium';
+import * as geo from '../../geo/index.js';
+import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
+import {
+ aircraftAtlasLayout,
+ createAircraftDisplay,
+} from './render/display.js';
+import flightsLayer, {
+ _setTrackedFlightRefreshStateForTest,
+ _updateTrackedModelForTest as flightsDriveTrackedModel,
+ _trackedBillboardColorForTest as flightsTrackedBillboardColor,
+} from '../../data/flights.js';
+import militaryFlightsLayer, {
+ _setTrackedMilitaryRefreshStateForTest,
+ _updateTrackedModelForTest as militaryDriveTrackedModel,
+ _trackedBillboardColorForTest as militaryTrackedBillboardColor,
+} from '../../data/militaryFlights.js';
+import { clearTr3bRegistry } from '../../data/tr3bRegistry.js';
+import { recordingAircraftRenderer } from '../../testSupport/aircraftRecordingRenderer.mjs';
+
+globalThis.window ??= {
+ dispatchEvent: () => true,
+ addEventListener() {},
+ removeEventListener() {},
+};
+
+const layout = aircraftAtlasLayout();
+const drawAtlas = async () => ({
+ canvas: { width: layout.width, height: layout.height },
+ cells: layout.cells,
+});
+
+test('a failed tracked model loads again in a new set once it is removed', async (t) => {
+ const engine = createFakeEngine();
+ t.after(() => engine.destroy());
+ const display = createAircraftDisplay(engine, {
+ layerId: 'flights',
+ headingOffsetDeg: 180,
+ drawAtlas,
+ });
+ t.after(() => display.destroy());
+ await display.whenReady();
+ display.fleet.attach();
+ const failing = new Set();
+ engine.fake.script('moverFailed', (id) => failing.has(id));
+ const spec = { url: 'plane.glb', minimumPixelSize: 64, scale: 1, id: 'abc' };
+
+ const first = await display.models.loadTracked(spec);
+ display.models.add(first);
+ const failedSet = first.gevBank.set.id;
+ assert.equal(first.failed, false);
+ failing.add(failedSet);
+ assert.equal(first.failed, true, 'the engine reports the failed load');
+ display.models.remove(first);
+
+ const retry = await display.models.loadTracked(spec);
+ display.models.add(retry);
+ assert.notEqual(retry.gevBank.set.id, failedSet, 'a new set loads it again');
+ assert.equal(retry.failed, false);
+});
+
+/** A viewer stub inside the tracked model regime. */
+function viewerAt(height) {
+ const viewer = {
+ camera: {
+ positionCartographic: { height },
+ positionWC: Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 5000),
+ frustum: { fovy: Cesium.Math.toRadians(60) },
+ },
+ scene: {
+ canvas: { clientHeight: 900 },
+ frameState: { frameNumber: 1 },
+ primitives: { length: 0, get() {} },
+ sampleHeight: () => undefined,
+ },
+ trackedEntity: undefined,
+ entities: { remove() {} },
+ };
+ viewer.engine = {
+ isDestroyed: () => false,
+ surface: { tilesLoaded: () => true, sampleHeightDetailed: () => undefined },
+ };
+ return viewer;
+}
+
+const LAYERS = [
+ {
+ name: 'flights',
+ layer: flightsLayer,
+ seed: _setTrackedFlightRefreshStateForTest,
+ drive: flightsDriveTrackedModel,
+ billboardColor: flightsTrackedBillboardColor,
+ meta: { callsign: 'TEST123 ', altitude: 10_668, klass: 'airliner' },
+ },
+ {
+ name: 'military',
+ layer: militaryFlightsLayer,
+ seed: _setTrackedMilitaryRefreshStateForTest,
+ drive: militaryDriveTrackedModel,
+ billboardColor: militaryTrackedBillboardColor,
+ meta: { callsign: 'TEST123 ', altitudeFt: 35_000, klass: 'fastjet' },
+ },
+];
+
+for (const fixture of LAYERS) {
+ test(`${fixture.name}: a tracked model the engine fails to load is retried, then given up`, async () => {
+ clearTr3bRegistry();
+ fixture.layer.setParams({ models3d: false });
+ const realNow = Date.now;
+ const realWarn = console.warn;
+ let now = 1_000_000;
+ Date.now = () => now;
+ const warnings = [];
+ console.warn = (...args) => warnings.push(args.join(' '));
+ let loads = 0;
+ const removed = [];
+ const viewer = viewerAt(30_000);
+ try {
+ const billboard = {
+ position: Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668),
+ color: null,
+ show: true,
+ rotation: 0,
+ };
+ fixture.seed({
+ renderer: () =>
+ recordingAircraftRenderer(viewer.engine, {
+ view: { camera: viewer.camera, scene: viewer.scene },
+ fleet: { shown: true },
+ models: {
+ live: () => true,
+ loadTracked: async (spec) => {
+ loads++;
+ return {
+ spec,
+ failed: true,
+ ready: false,
+ show: false,
+ modelMatrix: geo.matrix4.create(),
+ };
+ },
+ add() {},
+ remove: (model) => removed.push(model),
+ destroy() {},
+ setPose: () => geo.matrix4.create(),
+ },
+ }),
+ icao24: 'abc123',
+ entity: null,
+ billboard,
+ billboardCollection: { show: true, remove() {} },
+ viewer,
+ tracked: true,
+ meta: { ...fixture.meta, onGround: false },
+ });
+ for (let frame = 0; frame < 30; frame++) {
+ fixture.drive();
+ await Promise.resolve();
+ await Promise.resolve();
+ now += 2000; // past the retry backoff
+ }
+ assert.ok(loads > 1, `the failed load is tried again (${loads})`);
+ assert.ok(loads <= 3, `within the failure budget (${loads})`);
+ assert.equal(removed.length, loads, 'each failed model leaves the scene');
+ assert.equal(warnings.length, 1, 'the give-up is announced once');
+ assert.equal(
+ fixture.billboardColor().alpha,
+ 1,
+ 'the tracked glyph stays the visual',
+ );
+ } finally {
+ Date.now = realNow;
+ console.warn = realWarn;
+ fixture.layer.stopTracking();
+ }
+ });
+}
diff --git a/src/layers/aircraft/tracking.js b/src/layers/aircraft/tracking.js
index b1d525621..082fd70aa 100644
--- a/src/layers/aircraft/tracking.js
+++ b/src/layers/aircraft/tracking.js
@@ -779,8 +779,6 @@ export function createAircraftTracking({
skipViewerUntrack = false,
{ evicted = false, origin = 'programmatic' } = {},
) {
- flightState._trackedCameraFrameStop?.();
- flightState._trackedCameraFrameStop = null;
if (!flightState._trackedIcao) {
clearFocusTarget(focusId);
return;
@@ -910,16 +908,7 @@ export function createAircraftTracking({
// DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always
// resolves into an aircraft.
//
- // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by
- // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from
- // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF
- // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in
- // would stall the centering until the model loaded. A shown-but-transparent billboard always
- // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is
- // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap.
- // The tracked entity is a PURE BILLBOARD — no label or model graphic. The 3D model for the
- // tracked plane is a standalone primitive driven by _updateTrackedModel(); keeping it off the
- // entity is what makes the follow-camera's bounding sphere always ready (see _trackedModel).
+ // The tracked sprite becomes transparent only while the standalone model is ready and shown.
flightState._trackedEntity = flightState._renderer.tracked.create({
// Dead-reckoning position — smooth continuous motion between API updates.
position: getTrackedPosition,
@@ -1014,8 +1003,7 @@ export function createAircraftTracking({
const restoring = resume || origin === 'share-restore';
if (restoring) entity.gevRestoring = true;
try {
- flightState._trackedCameraFrameStop =
- flightState._renderer.follow.start(entity);
+ flightState._renderer.follow.start(entity);
} finally {
if (restoring) delete entity.gevRestoring;
}
diff --git a/src/layers/alpr/overlay.js b/src/layers/alpr/overlay.js
index 8f1164133..81f90196e 100644
--- a/src/layers/alpr/overlay.js
+++ b/src/layers/alpr/overlay.js
@@ -19,6 +19,8 @@ import {
validAlprGroundHeight,
} from './visuals.js';
+let overlaySequence = 0;
+
/**
* Canvas artwork for the nearest ALPR cameras over each engine's native
* markers, with bounded surface refinement. The engine adapter owns marker
@@ -341,7 +343,7 @@ export function createAlprOverlayCore({
if (!services.overlays?.registerPaintLane || typeof Image === 'undefined')
return;
lane = services.overlays.registerPaintLane('selected', paint, {
- id: `alpr-${globalThis.crypto.randomUUID()}`,
+ id: `alpr-${++overlaySequence}`,
active: false,
});
images = [MARKER_IMAGE, SELECTED_IMAGE, BRACKETS_IMAGE].map((src) => {
diff --git a/src/layers/alpr/overlayInsecureContext.test.mjs b/src/layers/alpr/overlayInsecureContext.test.mjs
new file mode 100644
index 000000000..c9c8cdadb
--- /dev/null
+++ b/src/layers/alpr/overlayInsecureContext.test.mjs
@@ -0,0 +1,45 @@
+// The ALPR paint lane must initialize in an insecure context (plain HTTP over
+// the HOST=0.0.0.0 LAN opt-in), where crypto.randomUUID does not exist.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { createAlprOverlayCore } from './overlay.js';
+
+test('ALPR overlay init works without crypto.randomUUID', (t) => {
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
+ const insecure = {
+ getRandomValues: (a) => globalThis.crypto.getRandomValues(a),
+ };
+ const hadImage = 'Image' in globalThis;
+ if (!hadImage)
+ globalThis.Image = class {
+ set src(_) {}
+ };
+ Object.defineProperty(globalThis, 'crypto', {
+ value: insecure,
+ configurable: true,
+ });
+ t.after(() => {
+ Object.defineProperty(globalThis, 'crypto', descriptor);
+ if (!hadImage) delete globalThis.Image;
+ });
+ let lane = null;
+ const overlay = createAlprOverlayCore({
+ state: { enabled: true },
+ services: {
+ overlays: {
+ subscribeMapStack: () => () => {},
+ registerPaintLane: (_name, _paint, options) => {
+ lane = { options, requestPaint() {}, remove() {}, destroy() {} };
+ return lane;
+ },
+ },
+ },
+ engine: {},
+ markers: new Map(),
+ syncMarker() {},
+ sampleHeight() {},
+ terrainHeight() {},
+ });
+ assert.doesNotThrow(() => overlay.init());
+ assert.match(lane?.options.id ?? '', /^alpr-/);
+});
diff --git a/src/layers/cctv/attachDisabled.test.mjs b/src/layers/cctv/attachDisabled.test.mjs
new file mode 100644
index 000000000..b0b004972
--- /dev/null
+++ b/src/layers/cctv/attachDisabled.test.mjs
@@ -0,0 +1,64 @@
+// A CCTV layer that was initialized and then switched off must stay hidden
+// across an engine switch: detachRenderer() + attachRenderer() on the new
+// engine while _enabled is false must not draw the camera markers. The active
+// camera is left for an obstruction probe on the new engine's surface.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import * as geo from '../../geo/index.js';
+import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
+import { loadLayerRenderers } from '../../app/layers/renderers.js';
+import cctvLayer, { _setCctvCoverageStateForTest } from '../../data/cctv.js';
+
+await loadLayerRenderers('cesium');
+
+test('disabled CCTV markers stay hidden after an engine switch', () => {
+ const first = createFakeEngine({
+ width: 1280,
+ height: 800,
+ altitude: 50_000,
+ });
+ const second = createFakeEngine({
+ width: 1280,
+ height: 800,
+ altitude: 50_000,
+ });
+ const position = geo.cartesian3.fromDegrees(0, 0, 10);
+ const record = {
+ camera: { id: 'cam-1', lat: 0, lon: 0, feedType: 'image' },
+ position,
+ coverageEntities: [],
+ activationDone: true,
+ };
+ _setCctvCoverageStateForTest({
+ engine: first,
+ records: [record],
+ activeCameraId: 'cam-1',
+ enabled: false,
+ });
+ try {
+ cctvLayer.detachRenderer();
+ cctvLayer.attachRenderer(null, { engine: second });
+ second.fake.frames(2);
+ const set = second.fake.moverSet('cctv');
+ assert.ok(set, 'marker set exists');
+ assert.equal(
+ record.activationDone,
+ false,
+ 'the new surface needs an activation probe',
+ );
+ assert.equal(record.surfaceProbePending, true);
+ assert.equal(set.store.count ?? set.count ?? 1, 1);
+ assert.equal(
+ set.store.shows[0],
+ 0,
+ 'marker of the disabled layer is hidden',
+ );
+ } finally {
+ try {
+ cctvLayer.detachRenderer();
+ } catch {}
+ _setCctvCoverageStateForTest({ enabled: false });
+ first.destroy();
+ second.destroy();
+ }
+});
diff --git a/src/layers/cctv/ingestion.js b/src/layers/cctv/ingestion.js
index f80469769..c81da24f5 100644
--- a/src/layers/cctv/ingestion.js
+++ b/src/layers/cctv/ingestion.js
@@ -29,6 +29,17 @@ export function createIngestion({
parts.model.projectionTilesReady()
) {
layerState._tilesReadyReenqueued = true;
+ const active = parts.selection.getActiveRecord();
+ if (active?.surfaceProbePending) {
+ active.surfaceProbePending = false;
+ parts.geometry.runActivationObstructionProbe(active);
+ parts.geometry.applyFrustumGeometry(
+ active,
+ parts.ground.groundAltFor(active),
+ );
+ active.activationDone = true;
+ changed = true;
+ }
// Per-regime resolution (Task 5): only records unresolved for the
// CURRENT surface regime need the completion pass. On globe stacks
// projectionTilesReady() is false while a (hidden) Google tileset
diff --git a/src/layers/cctv/ingestion.test.mjs b/src/layers/cctv/ingestion.test.mjs
index 0fc0d72d7..e60ca9719 100644
--- a/src/layers/cctv/ingestion.test.mjs
+++ b/src/layers/cctv/ingestion.test.mjs
@@ -26,6 +26,7 @@ function setup() {
},
},
navigation: { maybeAutoHop() {} },
+ selection: { getActiveRecord: () => record },
presentation: {
notifyListeners() {
calls.notify++;
@@ -100,3 +101,25 @@ test('a tick resuming after an engine switch released the renderer does no scene
assert.equal(sceneWork, 0);
assert.equal(h.calls.notify, 1);
});
+
+test('an engine switch probes the active camera once the new surface is ready', async () => {
+ const h = setup();
+ const probes = [];
+ h.parts.geometry = {
+ runActivationObstructionProbe: (record) => probes.push(['probe', record]),
+ applyFrustumGeometry: (record, ground) =>
+ probes.push(['geometry', record, ground]),
+ };
+ h.parts.ground.groundAltFor = () => 12;
+ h.record.surfaceProbePending = true;
+ h.state._tilesReadyReenqueued = false;
+ assert.equal((await h.layer.update()).changed, true);
+ assert.deepEqual(probes, [
+ ['probe', h.record],
+ ['geometry', h.record, 12],
+ ]);
+ assert.equal(h.record.activationDone, true, 're-selecting it is a no-op');
+ h.state._tilesReadyReenqueued = false;
+ await h.layer.update();
+ assert.equal(probes.length, 2, 'one probe per engine switch');
+});
diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js
index 9271e385f..4fc49467f 100644
--- a/src/layers/cctv/lifecycle.js
+++ b/src/layers/cctv/lifecycle.js
@@ -45,11 +45,11 @@ export function createLifecycle({
/** Records the camera pose the last view pass ran at. */
function noteViewPose() {
- const camera = layerState._viewer?.camera;
- if (!camera?.positionWC) return;
+ const camera = parts.renderer?.cameraPose();
+ if (!camera?.position) return;
layerState._viewPose = {
- position: geo.cartesian3.clone(camera.positionWC),
- direction: camera.directionWC && geo.cartesian3.clone(camera.directionWC),
+ position: geo.cartesian3.clone(camera.position),
+ direction: camera.direction && geo.cartesian3.clone(camera.direction),
};
}
@@ -59,16 +59,14 @@ export function createLifecycle({
* would otherwise rerun the full pass on every tick.
*/
function viewPoseChanged() {
- const camera = layerState._viewer?.camera;
+ const camera = parts.renderer?.cameraPose();
const pose = layerState._viewPose;
- if (!pose || !camera?.positionWC) return true;
- const height = Math.max(1, camera.positionCartographic?.height ?? 0);
- const moved = geo.cartesian3.distance(camera.positionWC, pose.position);
+ if (!pose || !camera?.position) return true;
+ const height = Math.max(1, camera.cartographic?.height ?? 0);
+ const moved = geo.cartesian3.distance(camera.position, pose.position);
if (moved > height * VIEW_MOTION_MIN_MOVE_FRACTION) return true;
- if (!pose.direction || !camera.directionWC) return false;
- return (
- geo.cartesian3.dot(camera.directionWC, pose.direction) < MIN_TURN_COS
- );
+ if (!pose.direction || !camera.direction) return false;
+ return geo.cartesian3.dot(camera.direction, pose.direction) < MIN_TURN_COS;
}
function viewMotionTick() {
@@ -428,6 +426,7 @@ export function createLifecycle({
const renderer = createRenderer(viewer, options);
layerState._restoringViewport = Boolean(options.waitForCameraRestore);
renderer.createMarkers();
+ const active = parts.selection.getActiveRecord();
for (const record of layerState._records) {
record.billboard = renderer.createMarker(
record.camera,
@@ -436,7 +435,13 @@ export function createLifecycle({
record.groundResolved = {};
record.groundSamples = {};
record.probeClampRangeM = null;
+ // The new surface is unprobed: update() probes the active camera
+ // once its tiles are in.
+ record.surfaceProbePending =
+ record === active && record.activationDone === true;
+ record.activationDone = false;
}
+ renderer.setMarkersVisible(layerState._enabled);
bindScene(viewer, options);
if (!layerState._restoringViewport && layerState._enabled)
this.resumeDisplay();
diff --git a/src/layers/cctv/render/display.js b/src/layers/cctv/render/display.js
index 083462942..0839b8fd3 100644
--- a/src/layers/cctv/render/display.js
+++ b/src/layers/cctv/render/display.js
@@ -569,6 +569,7 @@ export function createCctvDisplay(engine, { state, services = {} } = {}) {
get ready() {
return initialized && !destroyed && !engine.isDestroyed();
},
+ cameraPose: () => engine.camera.pose(),
cameraCartographic: () => engine.camera.pose().cartographic,
following: () => Boolean(engine.camera.following()),
globeVisible: () => !engine.maps.photorealActive(),
diff --git a/src/layers/cctv/rendering.js b/src/layers/cctv/rendering.js
index 4fabd559f..3c1e050ce 100644
--- a/src/layers/cctv/rendering.js
+++ b/src/layers/cctv/rendering.js
@@ -177,7 +177,7 @@ export function createRendering({
const minY = -height * VIEW_ICON_MARGIN;
const maxY = height * (1 + VIEW_ICON_MARGIN);
// Ground metres one pixel covers per metre of distance from the viewer.
- const fovy = layerState._viewer?.camera?.frustum?.fovy || Math.PI / 3;
+ const fovy = parts.renderer.cameraPose()?.fovy || Math.PI / 3;
const metresPerPixelPerMetre =
(2 * Math.tan(fovy / 2)) / Math.max(1, height);
const activeId = layerState._activeCameraId;
diff --git a/src/layers/cctv/viewFovy.test.mjs b/src/layers/cctv/viewFovy.test.mjs
new file mode 100644
index 000000000..5c46a4898
--- /dev/null
+++ b/src/layers/cctv/viewFovy.test.mjs
@@ -0,0 +1,57 @@
+// CCTV icon thinning must use the camera's real vertical FOV. In a 16:9
+// window with Cesium's default fov 60deg the vertical FOV is about 36deg;
+// main read it from viewer.camera.frustum.fovy. Two cameras 17 m apart seen
+// from 2 km: with fovy 36deg the cell is ~48 m (< VIEW_MIN_CELL_M, both shown),
+// with the hard-coded pi/3 fallback it is ~86 m and one camera is hidden.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { createRendering } from './rendering.js';
+import { isVideoFeedType } from '../../sources/cctvTypes.js';
+
+const FOVY_16_9 = 2 * Math.atan(Math.tan(Math.PI / 6) / (16 / 9));
+
+test('icon thinning uses the camera vertical FOV, not a pi/3 fallback', () => {
+ const record = (id, lat, x) => ({
+ camera: { id, lat, lon: 0, feedType: 'image' },
+ billboard: { show: true, position: { x, y: 0, z: 0 } },
+ });
+ const a = record('cam-a', 10.0005, 0);
+ const b = record('cam-b', 10.00065, 17);
+ const pose = { fovy: FOVY_16_9 };
+ const state = {
+ _enabled: true,
+ _records: [a, b],
+ _viewRecords: [],
+ _healthById: new Map(),
+ _activeCameraId: null,
+ };
+ const renderer = {
+ ready: true,
+ viewport: () => ({ width: 1920, height: 1080 }),
+ horizonTest: () => () => true,
+ project: () => ({ x: 960, y: 540 }),
+ distance: () => 2000,
+ // Accessor the fix is expected to add (any name works if the stub follows).
+ cameraPose: () => pose,
+ fovy: () => pose.fovy,
+ engine: { camera: { pose: () => pose } },
+ };
+ const { refreshHorizonCulling } = createRendering({
+ state,
+ services: {
+ focus: {
+ focusNowMs: (n) => n,
+ getFocusTarget: () => null,
+ focusPassIsNeeded: () => false,
+ advanceSpriteFocus: () => ({ factor: 1 }),
+ focusAlphaNeedsWrite: () => false,
+ },
+ },
+ parts: { renderer, model: { isVideoFeedType } },
+ source: {},
+ });
+ refreshHorizonCulling();
+ assert.equal(a.billboard.show, true, 'cam-a shown');
+ assert.equal(b.billboard.show, true, 'cam-b shown');
+ assert.equal(state._viewRecords.length, 2);
+});
diff --git a/src/layers/directions/render/display.js b/src/layers/directions/render/display.js
index 27f593578..d65ab1c44 100644
--- a/src/layers/directions/render/display.js
+++ b/src/layers/directions/render/display.js
@@ -206,6 +206,12 @@ export function createDirectionsDisplay(engine) {
const removeMoveEnd = engine.camera.moveEnd.addEventListener(() => {
if (ends.a || ends.b) placeMarkers();
});
+ let tilesLoaded = engine.surface.tilesLoaded();
+ const removeSurface = engine.frames.preRender.addEventListener(() => {
+ const loaded = engine.surface.tilesLoaded();
+ if (loaded && !tilesLoaded && visible && (ends.a || ends.b)) placeMarkers();
+ tilesLoaded = loaded;
+ });
placeMarkers();
return {
@@ -276,6 +282,7 @@ export function createDirectionsDisplay(engine) {
if (destroyed) return;
destroyed = true;
removeMoveEnd();
+ removeSurface();
route?.destroy();
steps?.destroy();
markers.destroy();
diff --git a/src/layers/directions/render/display.test.mjs b/src/layers/directions/render/display.test.mjs
index efd2aefda..146031abb 100644
--- a/src/layers/directions/render/display.test.mjs
+++ b/src/layers/directions/render/display.test.mjs
@@ -4,6 +4,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../../geo/index.js';
+import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs';
import { createApplicationDirectionsLayer } from '../../../data/directions.js';
import {
@@ -324,3 +325,29 @@ test('an engine switch redraws the route, dots and markers on the new engine onl
assert.equal(movers(IDS.markers).rows[1].show, true);
f.layer.detachRenderer();
});
+
+test('route endpoints refine when the surface finishes loading', (t) => {
+ const engine = createFakeEngine();
+ t.after(() => engine.destroy());
+ let height;
+ let loaded = false;
+ engine.fake.script('sampleHeight', () => height);
+ engine.fake.script('tilesLoaded', () => loaded);
+ const display = createDirectionsDisplay(engine);
+ t.after(() => display.destroy());
+ display.setMarker('a', { lon: 0, lat: 0 });
+ engine.fake.frames(1);
+ height = 340;
+ loaded = true;
+ engine.fake.frames(1);
+ const { positions } = engine.fake.moverSet('directions-markers').store;
+ assert.ok(
+ Math.abs(
+ geo.cartographic.fromCartesian({
+ x: positions[0],
+ y: positions[1],
+ z: positions[2],
+ }).height - 340,
+ ) < 0.01,
+ );
+});
diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js
index bc60a3e4b..e51f3139c 100644
--- a/src/layers/flights/lifecycle.js
+++ b/src/layers/flights/lifecycle.js
@@ -1,13 +1,6 @@
import { createAircraftSceneBinding } from '../aircraft/sceneBinding.js';
-import { PLANE_MODEL_URL } from './policy.js';
-export function createLifecycle({
- flightState,
- services,
- parts,
- layer,
- resolveAsset,
-}) {
+export function createLifecycle({ flightState, services, parts, layer }) {
const { clearFocusTarget } = services.focus;
const { onMilitaryLayerActiveChange } = services.militaryRegistry;
const { holdContinuousRender, releaseContinuousRender } = services.render;
@@ -19,7 +12,6 @@ export function createLifecycle({
flightState,
services,
parts,
- resolveModel: () => resolveAsset(PLANE_MODEL_URL),
enable: () => methods.enable(),
});
diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js
index a1dbf5d8a..a3a821ba2 100644
--- a/src/layers/flights/queries.js
+++ b/src/layers/flights/queries.js
@@ -764,9 +764,7 @@ export function createQueries({
// follow): asking to track it again follows it again.
const entity = flightState._trackedEntity;
if (entity && !flightState._renderer?.follow.isFollowing(entity)) {
- flightState._trackedCameraFrameStop?.();
- flightState._trackedCameraFrameStop =
- flightState._renderer.follow.refocus(entity);
+ flightState._renderer.follow.refocus(entity);
}
return parts.tracking._publishTrackedSelection(id, origin);
}
@@ -842,9 +840,7 @@ export function createQueries({
!flightState._renderer.follow.isFollowing(flightState._trackedEntity)
)
return false;
- flightState._trackedCameraFrameStop?.();
- flightState._trackedCameraFrameStop =
- flightState._renderer.follow.refocus(flightState._trackedEntity);
+ flightState._renderer.follow.refocus(flightState._trackedEntity);
parts.tracking._publishTrackedSelection(id, origin);
return true;
},
diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js
index 9e2872778..fb2dc7c51 100644
--- a/src/layers/flights/rendering.js
+++ b/src/layers/flights/rendering.js
@@ -555,7 +555,6 @@ export function createRendering({
model.show = false;
flightState._renderer.models.add(model);
flightState._models.set(icao24, model);
- flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard
}
/** Remove the 3D model for ONE aircraft (removal / military-suppression / track handoff).
@@ -720,13 +719,7 @@ export function createRendering({
}
flightState._trackedModel = m;
flightState._trackedModelLoading = false;
- // A good load retires this selection's failure budget — a contact that
- // recovers after a transient blip is not one attempt from giving up.
- flightState._trackedModelFailIcao = null;
- flightState._trackedModelFailCount = 0;
- flightState._trackedModelRetryAtMs = 0;
flightState._renderer.models.add(m);
- flightState._planeModelLoaded = true; // GLB cached — the tracked billboard can fade out once the model is up
})
.catch((err) => {
if (gen !== flightState._trackedModelGen) return; // superseded load — not this selection's failure
@@ -735,6 +728,17 @@ export function createRendering({
});
return; // billboard carries the visual until the model is ready
}
+ if (flightState._trackedModel?.failed) {
+ // The engine could not load the glTF: the billboard stays the visual
+ // and the load is tried again after the backoff.
+ const { url } = flightState._trackedModel.spec ?? {};
+ _releaseTrackedModel();
+ parts.tracking._noteTrackedModelLoadFailure(
+ url,
+ new Error('model failed to load'),
+ );
+ return;
+ }
if (flightState._trackedModel) {
// Keep the transform current while GPU resources are still loading. Cesium
// can flip ready during scene update after this callback; waiting for ready
@@ -754,6 +758,13 @@ export function createRendering({
flightState._trackedModel,
);
if (!flightState._trackedModel.ready) return;
+ // A model that loaded retires this selection's failure budget: a contact
+ // that recovers after a transient blip is not one attempt from giving up.
+ if (flightState._trackedModelFailIcao === flightState._trackedIcao) {
+ flightState._trackedModelFailIcao = null;
+ flightState._trackedModelFailCount = 0;
+ flightState._trackedModelRetryAtMs = 0;
+ }
const spec = _modelSpec(
flightState.records.data.get(flightState._trackedIcao)?.klass,
);
diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js
index e5b011f54..ee86eb069 100644
--- a/src/layers/flights/snapshotRenderer.js
+++ b/src/layers/flights/snapshotRenderer.js
@@ -25,6 +25,17 @@ export function createFlightSnapshotRenderer({
const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = militaryRegistry;
const { warmGroundFloor } = groundFloor;
const { sampleMeshFloorCells } = meshFloor;
+ // A low contact is horizon-culled from 12 m up: its ground can lie below
+ // the ellipsoid.
+ function setCullPosition(id, meta) {
+ flightState._cullPositions.set(
+ id,
+ meta.renderAltitudeM < 10
+ ? geo.cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12)
+ : null,
+ );
+ }
+
function presentContact(icao24, meta, position, prevMeta, groundFlipped) {
const isTracked = icao24 === flightState._trackedIcao;
if (flightState._billboards.has(icao24)) {
@@ -101,6 +112,7 @@ export function createFlightSnapshotRenderer({
meta.rawLat,
meta.renderAltitudeM,
);
+ setCullPosition(id, meta);
presentContact(id, meta, position, null, false);
}
}
@@ -164,12 +176,7 @@ export function createFlightSnapshotRenderer({
);
// Either flip retires the previous one-shot ground sample.
if (groundFlipped) flightState._groundSnap.forget(icao24);
- flightState._cullPositions.set(
- icao24,
- meta.renderAltitudeM < 10
- ? geo.cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12)
- : null,
- );
+ setCullPosition(icao24, meta);
const isTracked = icao24 === flightState._trackedIcao;
// Append to position history stamped with the FEED's fix epoch
diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js
index 1308aff95..cd4cc55f9 100644
--- a/src/layers/flights/state.js
+++ b/src/layers/flights/state.js
@@ -73,18 +73,6 @@ export function createFlightState({ source, services }) {
flightState._lastModelCapWarnMs = 0;
// throttle the "more planes in view than the cap" console notice
- // The tracked entity's billboard goes transparent (rather than hidden) once the model takes
- // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its
- // alpha after the GLB is preloaded so the model is ready to render the instant the billboard
- // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not
- // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation.
-
- flightState._planeModelLoaded = false;
-
- /** @type {object|null} retained preload that keeps the glTF cache warm */
-
- flightState._preloadModel = null;
-
/** Last limb taper per billboard, retained across class/ground/cockpit repaints. */
flightState._billboardLimbScale = new WeakMap();
@@ -126,10 +114,6 @@ export function createFlightState({ source, services }) {
flightState._trackedEntity = null;
- /** Disposes the single active tracked-camera framing owner. */
-
- flightState._trackedCameraFrameStop = null;
-
/** @type {object|null} Standalone 3D model for the tracked aircraft. Deliberately NOT a
* graphic on _trackedEntity: viewer.trackedEntity derives the follow-camera from the entity's
* bounding sphere, and a model graphic reports PENDING until its glTF loads — which stalls (or, on
diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js
index 9f4bae5ee..b815a1607 100644
--- a/src/layers/flights/testing.js
+++ b/src/layers/flights/testing.js
@@ -198,6 +198,20 @@ export function createTesting({
await parts.rendering._ensureModel(icao24);
return flightState._models.get(icao24) || null;
}
+
+ /** The renderer handles one contact draws with, for QA probes that tamper
+ * with them: its glyph and fleet model, and the tracked glyph and model
+ * while it is tracked. */
+
+ function _aircraftHandlesForTest(icao24) {
+ const tracked = icao24 === flightState._trackedIcao;
+ return {
+ sprite: flightState._billboards.get(icao24) ?? null,
+ model: flightState._models.get(icao24) ?? null,
+ trackedSprite: tracked ? flightState._trackedEntity : null,
+ trackedModel: tracked ? flightState._trackedModel : null,
+ };
+ }
return {
_floorGroundedDisplayPositionForTest,
_clearDisplayFloorStateForTest,
@@ -214,5 +228,6 @@ export function createTesting({
_trackedBillboardColorForTest,
_driveFleetModelHandoffForTest,
_ensureFleetModelForTest,
+ _aircraftHandlesForTest,
};
}
diff --git a/src/layers/ghostMaps/edgeCases.test.mjs b/src/layers/ghostMaps/edgeCases.test.mjs
index 2aec18bb9..d12f73a31 100644
--- a/src/layers/ghostMaps/edgeCases.test.mjs
+++ b/src/layers/ghostMaps/edgeCases.test.mjs
@@ -86,6 +86,11 @@ function fakeRenderer(name, live) {
id,
names: [...features.values()].map((feature) => feature.name),
ready: Promise.resolve(),
+ drop(index) {
+ paint.names = [...features]
+ .filter(([key]) => key !== index)
+ .map(([, feature]) => feature.name);
+ },
destroy() {
live.delete(paint);
},
diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js
index 1aa98f9a7..491271c20 100644
--- a/src/layers/ghostMaps/index.js
+++ b/src/layers/ghostMaps/index.js
@@ -209,21 +209,18 @@ export function createGhostMapsLayer({
}
// Remove expired events, rather than hiding them: horizon culling must
- // never make an old pin visible again. A drawing that cannot drop one
- // report is redrawn. Map keys keep surviving picks stable.
+ // never make an old pin visible again. Map keys keep surviving picks stable.
function expireEvents() {
const at = now();
let changed = false;
for (const [productId, item] of _loaded) {
- let redraw = false;
for (const [index, feature] of item.features) {
if (!isGhostMapsEventExpired(feature.dateMs, at, _ageDays)) continue;
item.features.delete(index);
- if (item.paint && !item.paint.drop?.(index)) redraw = true;
+ item.paint?.drop(index);
if (_selectedKey === `${productId}:${index}`) clearSelection();
changed = true;
}
- if (redraw) repaint(productId, item);
}
if (changed) {
notifyRow();
@@ -231,22 +228,6 @@ export function createGhostMapsLayer({
}
}
- // The new drawing is on the globe before the old one leaves.
- function repaint(id, item) {
- _queue = _queue.then(async () => {
- if (_loaded.get(id) !== item) return;
- const old = item.paint;
- item.paint = null;
- try {
- await paint(id, item, _lifetime.signal);
- } catch (error) {
- console.warn(`[Data:GhostMaps] ${id}:`, error);
- } finally {
- if (item.paint !== old) old?.destroy();
- }
- });
- }
-
/** Decode one product without a scene. */
async function load(product, signal) {
const ageDays = _ageDays;
@@ -302,19 +283,25 @@ export function createGhostMapsLayer({
PRODUCT_COLORS[id] || ACCENT,
markerImage,
);
+ item.paint = next;
+ const abort = () => next.destroy();
+ signal.addEventListener('abort', abort, { once: true });
try {
await next.ready;
if (owner === _display && !signal.aborted && !item.removed) {
- item.paint = next;
return;
}
} catch (error) {
next.destroy();
+ if (item.paint === next) item.paint = null;
if (owner === _display && !signal.aborted && !item.removed) throw error;
if (_loaded.get(id) === item) return;
continue;
+ } finally {
+ signal.removeEventListener('abort', abort);
}
next.destroy();
+ if (item.paint === next) item.paint = null;
if (_loaded.get(id) === item) return;
}
}
diff --git a/src/layers/ghostMaps/pendingPaint.test.mjs b/src/layers/ghostMaps/pendingPaint.test.mjs
new file mode 100644
index 000000000..be1b6a00e
--- /dev/null
+++ b/src/layers/ghostMaps/pendingPaint.test.mjs
@@ -0,0 +1,167 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createGhostMapsLayer } from './index.js';
+
+function element(localName, children = [], value = '') {
+ const node = {
+ localName,
+ children,
+ get textContent() {
+ return value + children.map((child) => child.textContent).join('');
+ },
+ getAttribute: () => null,
+ getElementsByTagName(name) {
+ return children.flatMap((child) => [
+ ...(name === '*' || name === child.localName ? [child] : []),
+ ...child.getElementsByTagName(name),
+ ]);
+ },
+ };
+ for (const child of children) child.parentElement = node;
+ return node;
+}
+
+// The XML double turns `NAME` into one placemark called NAME.
+function documentFor(xml) {
+ const name = /(.*)<\/kml>/.exec(xml)?.[1] ?? 'unnamed';
+ return element('document', [
+ element('kml', [
+ element('Document', [
+ element('Placemark', [
+ element('name', [], name),
+ element(
+ 'description',
+ [],
+ '',
+ ),
+ element('Point', [element('coordinates', [], '12,34')]),
+ ]),
+ ]),
+ ]),
+ ]);
+}
+
+/** A stored (uncompressed) KMZ holding doc.kml only. */
+function kmz(name) {
+ const key = Buffer.from('doc.kml');
+ const bytes = Buffer.from(`${name}`);
+ const header = Buffer.alloc(30);
+ header.writeUInt32LE(0x04034b50);
+ header.writeUInt32LE(bytes.length, 18);
+ header.writeUInt32LE(bytes.length, 22);
+ header.writeUInt16LE(key.length, 26);
+ const directory = Buffer.alloc(46);
+ directory.writeUInt32LE(0x02014b50);
+ directory.writeUInt32LE(bytes.length, 20);
+ directory.writeUInt32LE(bytes.length, 24);
+ directory.writeUInt16LE(key.length, 28);
+ directory.writeUInt32LE(0, 42);
+ const central = Buffer.concat([directory, key]);
+ const end = Buffer.alloc(22);
+ end.writeUInt32LE(0x06054b50);
+ end.writeUInt16LE(1, 8);
+ end.writeUInt16LE(1, 10);
+ end.writeUInt32LE(central.length, 12);
+ end.writeUInt32LE(header.length + key.length + bytes.length, 16);
+ return new Uint8Array(Buffer.concat([header, key, bytes, central, end]));
+}
+
+function deferred() {
+ let resolve;
+ const promise = new Promise((done) => (resolve = done));
+ return { promise, resolve };
+}
+
+const catalog = (version) => ({
+ products: [{ id: 'cip', version, defaultOn: true }],
+});
+const names = (layer) => layer.getAnalystRecords().map((record) => record.name);
+
+const DAY = 86400_000;
+
+for (const action of ['expire', 'disable']) {
+ test(`${action} removes a drawing while its icons decode`, async (t) => {
+ const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser');
+ globalThis.DOMParser = class {
+ parseFromString(xml) {
+ return documentFor(xml);
+ }
+ };
+ t.after(() => {
+ if (previous) Object.defineProperty(globalThis, 'DOMParser', previous);
+ else delete globalThis.DOMParser;
+ });
+ t.mock.method(console, 'warn', () => {});
+ let clock = Date.UTC(2026, 0, 12);
+ const live = new Set();
+ const gates = [];
+ const layer = createGhostMapsLayer({
+ storage: null,
+ now: () => clock,
+ createRenderer: (engine) => ({
+ requestRender() {},
+ onClick: () => () => {},
+ draw(id, features) {
+ const gate = deferred();
+ if (engine.name === 'navara') gates.push(gate);
+ else gate.resolve();
+ const paint = {
+ engine: engine.name,
+ names: [...features.values()].map((feature) => feature.name),
+ dropped: [],
+ ready: gate.promise,
+ drop(index) {
+ paint.dropped.push(index);
+ paint.names = [];
+ return true;
+ },
+ destroy() {
+ live.delete(paint);
+ },
+ };
+ live.add(paint);
+ return paint;
+ },
+ destroy() {},
+ }),
+ source: {
+ getCatalog: async () => catalog('v1'),
+ getFile: async () => kmz('old event'),
+ },
+ });
+ layer.init(null, { engine: { name: 'cesium' } });
+ layer.enable();
+ t.after(() => layer.destroy());
+ await layer.update();
+ assert.deepEqual(names(layer), ['old event']);
+
+ // Engine switch: the new drawing is still decoding its icons.
+ layer.detachRenderer();
+ const attached = layer.attachRenderer(null, { engine: { name: 'navara' } });
+ await new Promise(setImmediate);
+ assert.equal(gates.length, 1);
+ // The event leaves its age window; the expiry pass runs meanwhile.
+ if (action === 'expire') {
+ clock += 30 * DAY;
+ await layer.update();
+ } else {
+ layer.disable();
+ assert.equal(
+ live.size,
+ 0,
+ 'disabled drawings are removed before decoding finishes',
+ );
+ }
+ assert.deepEqual(names(layer), [], 'the record is expired');
+ gates[0].resolve();
+ await attached;
+ for (let i = 0; i < 10; i += 1) await new Promise(setImmediate);
+
+ const drawn = [...live].filter((paint) => paint.names.length);
+ assert.deepEqual(
+ drawn.map((paint) => `${paint.engine}:${paint.names.join(',')}`),
+ [],
+ 'no drawing keeps the expired event',
+ );
+ });
+}
diff --git a/src/layers/ghostMaps/render/display.js b/src/layers/ghostMaps/render/display.js
index ef93c924e..6dbe3bbbb 100644
--- a/src/layers/ghostMaps/render/display.js
+++ b/src/layers/ghostMaps/render/display.js
@@ -8,11 +8,13 @@
// outline adds `:outline`), which the layer resolves to its report.
// Placemarks draw over the scene (depth test off) and are hidden beyond the
// horizon instead, as they would sink into terrain and 3D tiles otherwise.
+// They stand on the sampled surface, measured for the ones on screen.
import * as geo from '../../../geo/index.js';
import { normalizeGroundItem } from '../../../globe/ground.js';
import { rgbaFromCss } from '../../shared/color.js';
import { imageAtlasLayout } from '../../shared/render/imageAtlas.js';
+import { createSurfacePoints } from '../../shared/render/surfacePoints.js';
const LAYER_ID = 's2-ghostmaps';
const TRANSPARENT = Object.freeze([0, 0, 0, 0]);
@@ -85,7 +87,7 @@ function editionItems(productId, features, color, markerImage) {
report: index,
id,
url,
- // On the ellipsoid: movers have no ground clamp.
+ cartographic: geo.cartographic.fromDegrees(lon, lat),
position: geo.cartesian3.fromDegrees(lon, lat),
horizon: geo.cartesian3.fromDegrees(lon, lat, HORIZON_LIFT_M),
scale: ghostMapsIconScale(style),
@@ -201,6 +203,11 @@ export function createGhostMapsDisplay(
let removeCull = null;
const lastCamera = geo.cartesian3.create(Number.NaN, 0, 0);
const request = () => engine.render.request(LAYER_ID);
+ const surface = createSurfacePoints(engine, {
+ groups: () =>
+ [...paints].map((paint) => ({ set: paint.sprites, points: paint.pins })),
+ request,
+ });
// Hides the placemarks beyond the horizon; true when any show changed.
function cull(paint) {
@@ -289,6 +296,7 @@ export function createGhostMapsDisplay(
pin.show = engine.projector.horizonVisible(pin.horizon);
writePins(paint);
syncCull();
+ surface.place();
}
return {
@@ -341,6 +349,7 @@ export function createGhostMapsDisplay(
syncCull();
request();
};
+ paint.dispose = dispose;
let ready;
try {
commitGround(paint);
@@ -378,9 +387,10 @@ export function createGhostMapsDisplay(
destroy() {
if (destroyed) return;
destroyed = true;
+ surface.destroy();
removeCull?.();
removeCull = null;
- paints.clear();
+ for (const paint of paints) paint.dispose();
},
};
}
diff --git a/src/layers/ghostMaps/render/display.test.mjs b/src/layers/ghostMaps/render/display.test.mjs
index c3a06aea7..a2b0a6ac2 100644
--- a/src/layers/ghostMaps/render/display.test.mjs
+++ b/src/layers/ghostMaps/render/display.test.mjs
@@ -414,3 +414,79 @@ test('an edition destroyed while its icons decode draws no placemarks', async ()
/destroyed/,
);
});
+
+test('placemarks follow the loaded surface and refine after tiles settle', async (t) => {
+ const engine = createFakeEngine();
+ t.after(() => engine.destroy());
+ engine.projector.worldToWindow = () => ({ x: 100, y: 100 });
+ let height = 350;
+ let loaded = false;
+ engine.fake.script('terrainHeight', () => height);
+ engine.fake.script('tilesLoaded', () => loaded);
+ const display = createGhostMapsDisplay(engine, iconSeams());
+ t.after(() => display.destroy());
+ const paint = display.draw(
+ 'cip',
+ new Map([
+ [
+ 0,
+ {
+ style: {},
+ geometries: [{ type: 'point', position: [0, 0] }],
+ },
+ ],
+ ]),
+ PRODUCT,
+ markerImage,
+ );
+ t.after(() => paint.destroy());
+ await paint.ready;
+ const altitude = () => {
+ const { store } = engine.fake.moverSet('s2-ghostmaps:cip:1:pins');
+ return geo.cartographic.fromCartesian({
+ x: store.positions[0],
+ y: store.positions[1],
+ z: store.positions[2],
+ }).height;
+ };
+ assert.ok(Math.abs(altitude() - 350) < 0.01);
+ engine.fake.frames(1);
+ height = 420;
+ loaded = true;
+ engine.fake.frames(1);
+ assert.ok(Math.abs(altitude() - 420) < 0.01);
+});
+
+test('a large edition measures only on-screen placemarks, a few per pass', async (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ const engine = createFakeEngine();
+ t.after(() => engine.destroy());
+ let onScreen = 0;
+ engine.projector.worldToWindow = () =>
+ onScreen-- > 0 ? { x: 100, y: 100 } : undefined;
+ let samples = 0;
+ engine.fake.script('terrainHeight', () => {
+ samples++;
+ return 30;
+ });
+ const display = createGhostMapsDisplay(engine, iconSeams());
+ t.after(() => display.destroy());
+ const features = new Map(
+ Array.from({ length: 2000 }, (_, index) => [
+ index,
+ {
+ style: {},
+ geometries: [{ type: 'point', position: [index / 100, 0] }],
+ },
+ ]),
+ );
+ onScreen = 40;
+ const paint = display.draw('cip', features, PRODUCT, markerImage);
+ t.after(() => paint.destroy());
+ await paint.ready;
+ assert.ok(samples > 0 && samples <= 16, `first pass sampled ${samples}`);
+ t.mock.timers.tick(0);
+ t.mock.timers.tick(0);
+ t.mock.timers.tick(0);
+ assert.equal(samples, 40, 'every on-screen placemark, none off screen');
+});
diff --git a/src/layers/infrastructure/render/display.js b/src/layers/infrastructure/render/display.js
index a8a623895..380d39521 100644
--- a/src/layers/infrastructure/render/display.js
+++ b/src/layers/infrastructure/render/display.js
@@ -658,6 +658,7 @@ export function createInfrastructureDisplay(
})
: null,
};
+ row.__localBaseCartesian = row.base;
rows.set(row.id, row);
services.registerEntityContext?.(row, {
id: row.pickId,
diff --git a/src/layers/infrastructure/render/inViewContext.test.mjs b/src/layers/infrastructure/render/inViewContext.test.mjs
new file mode 100644
index 000000000..1a43426a8
--- /dev/null
+++ b/src/layers/infrastructure/render/inViewContext.test.mjs
@@ -0,0 +1,84 @@
+// get_entity_context in_view must list a datacenter/dam drawn in the middle of
+// the screen. The infrastructure display registers its context rows without
+// __localBaseCartesian, which visibleEntityContexts (src/voice/gevActions.js)
+// requires, so every infrastructure record is skipped.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as geo from '../../../geo/index.js';
+import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
+import { registerEntityContext } from '../../../data/contextStore.js';
+import { createGevActionRunner } from '../../../voice/gevActions.js';
+import { createInfrastructureDisplay } from './display.js';
+
+globalThis.window ||= { setTimeout, clearTimeout, requestIdleCallback: null };
+globalThis.window.addEventListener ||= () => {};
+globalThis.window.removeEventListener ||= () => {};
+globalThis.window.dispatchEvent ||= () => true;
+
+const DAMS = {
+ id: 'local-dams',
+ name: 'Dams',
+ color: '#0088ff',
+ source: 'USACE',
+ osmDerived: true,
+ labels: false,
+};
+
+test('in_view lists an on-screen dam', async (t) => {
+ const engine = createFakeEngine({
+ longitude: geo.math.toRadians(-97.7),
+ latitude: geo.math.toRadians(30.2),
+ altitude: 20_000,
+ width: 800,
+ height: 600,
+ });
+ const display = createInfrastructureDisplay(engine, {
+ config: DAMS,
+ services: {
+ registerEntityContext,
+ overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
+ },
+ });
+ t.after(() => {
+ display.destroy();
+ engine.destroy();
+ });
+ display.show({
+ features: [
+ {
+ type: 'Feature',
+ id: 'dam-1',
+ properties: { name: 'Runtime Dam' },
+ geometry: { type: 'Point', coordinates: [-97.7, 30.2] },
+ },
+ ],
+ records: [
+ {
+ id: 'dam-1',
+ lon: -97.7,
+ lat: 30.2,
+ properties: { name: 'Runtime Dam' },
+ geometry: { type: 'Point', coordinates: [-97.7, 30.2] },
+ },
+ ],
+ });
+ const dataManager = {
+ layers: new Map(),
+ isEnabled: (id) => id === 'local-dams',
+ getAll: () => [
+ { id: 'local-dams', name: 'Dams', enabled: true, stats: {} },
+ ],
+ };
+ const runner = createGevActionRunner({
+ viewer: null,
+ engine,
+ styleManager: { runImmediateNavigation: (n, go) => go() },
+ dataManager,
+ });
+ const result = await runner('get_entity_context', { scope: 'in_view' });
+ assert.equal(result.ok, true);
+ assert.ok(
+ result.visible.some((entry) => String(entry.id).includes('dam-1')),
+ `visible: ${JSON.stringify(result.visible)}`,
+ );
+});
diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js
index 56fca7736..7a2602847 100644
--- a/src/layers/launches/lifecycle.js
+++ b/src/layers/launches/lifecycle.js
@@ -113,6 +113,7 @@ export function createLifecycle({
parts.selection.startMissionZoomAnchor(
launch,
layerState._missionZoomRange,
+ { resuming: true },
);
}
layerState._resumeRenderer?.();
@@ -124,6 +125,9 @@ export function createLifecycle({
layerState._waitForRenderer ??= new Promise((resolve) => {
layerState._resumeRenderer = resolve;
});
+ layerState._replayCameraHeading = layerState._replayTracks.get(
+ layerState._replayCameraLaunchId,
+ )?.lastCameraHeading;
layerState._replayCameraToken++;
layerState._replayCameraRemover?.();
layerState._missionZoomAnchorRemover?.();
@@ -177,7 +181,7 @@ export function createLifecycle({
getRenderer()?.setVisible(true);
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
// Snapshot the existing follow before the mission flight releases it.
- await parts.ingestion.captureSatelliteDependency();
+ const dependency = parts.ingestion.captureSatelliteDependency();
if (layerState._lastUpdate === null) {
// A new attempt at an index that never loaded starts from loading.
layerState._lastError = null;
@@ -205,6 +209,7 @@ export function createLifecycle({
layerState._explicitSelection,
);
else parts.selection.setSelectedMission(null, false);
+ await dependency;
},
async disable() {
diff --git a/src/layers/launches/lifecycleSnapshot.test.mjs b/src/layers/launches/lifecycleSnapshot.test.mjs
index b8a20b7bd..6e3fe5615 100644
--- a/src/layers/launches/lifecycleSnapshot.test.mjs
+++ b/src/layers/launches/lifecycleSnapshot.test.mjs
@@ -38,3 +38,57 @@ test('missions preserve the satellite target before their focus flight releases
else globalThis.document = previous;
}
});
+
+test('missions begin their entry flight while the satellite catalog loads', async () => {
+ let resolve;
+ const pending = new Promise((done) => {
+ resolve = done;
+ });
+ let flew = false;
+ const lifecycle = createLifecycle({
+ state: { _engine: {} },
+ services: { render: {} },
+ getRenderer: () => ({ setVisible() {} }),
+ parts: {
+ orbitRendering: { syncMissionOrbitPrimitiveVisibility() {} },
+ selection: {
+ focusFullGlobe() {
+ flew = true;
+ },
+ setSelectedMission() {},
+ },
+ ingestion: { captureSatelliteDependency: () => pending },
+ },
+ });
+ const enabling = lifecycle.methods._enableBody();
+ assert.equal(flew, true);
+ resolve();
+ await enabling;
+});
+
+test('detaching retains the active replay heading before replacing its track', () => {
+ const state = {
+ _renderer: { destroy() {} },
+ _replayCameraLaunchId: 'mission',
+ _replayTracks: new Map([['mission', { lastCameraHeading: 1.2 }]]),
+ _missionOverlayRecords: new Map(),
+ _satelliteTelemetry: new Map(),
+ };
+ const noop = () => {};
+ const lifecycle = createLifecycle({
+ state,
+ services: { render: {} },
+ parts: {
+ launchPad: { destroyLaunchPadZonePrimitive: noop },
+ orbitRendering: { removeMissionOrbitPrimitives: noop },
+ overlays: {
+ clearMissionOverlaySources: noop,
+ destroyReplayVehicleOverlay: noop,
+ },
+ rendering: { removeMissionElements: noop },
+ },
+ });
+ lifecycle.methods.detachRenderer();
+ assert.equal(state._replayTracks.size, 0);
+ assert.equal(state._replayCameraHeading, 1.2);
+});
diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js
index db51f635b..98ab6adf9 100644
--- a/src/layers/launches/replay.js
+++ b/src/layers/launches/replay.js
@@ -367,7 +367,10 @@ export function createReplay({
const initialHeading = parts.camera.replayInitialCameraHeading(
track.ascentPath,
);
- track.lastCameraHeading = initialHeading;
+ track.lastCameraHeading =
+ resuming && Number.isFinite(layerState._replayCameraHeading)
+ ? layerState._replayCameraHeading
+ : initialHeading;
syncReplayButton();
parts.overlays.syncMissionOverlayEntries();
if (!resuming) {
diff --git a/src/layers/launches/replay.test.mjs b/src/layers/launches/replay.test.mjs
index ded0a6a0f..b3d10b1d2 100644
--- a/src/layers/launches/replay.test.mjs
+++ b/src/layers/launches/replay.test.mjs
@@ -45,3 +45,25 @@ test('changing replay speed during the countdown keeps its real-time liftoff', (
// Mission time already flown is still preserved across the change.
assert.equal(state._animationStarts.get('flying'), 960_000);
});
+
+test('resuming a replay restores its retained chase heading', () => {
+ const { state } = replayRig();
+ state._engine = {
+ frames: { preUpdate: { addEventListener: () => () => {} } },
+ };
+ state._launches = [{ id: 'mission' }];
+ state._replayCameraLaunchId = 'mission';
+ state._replayCameraHeading = 1.2;
+ const track = { ascentPath: [], ascentDurationSec: 10 };
+ state._replayTracks.set('mission', track);
+ const replay = createReplay({
+ state,
+ parts: {
+ camera: { replayInitialCameraHeading: () => 2.3 },
+ panel: { publishPanel: noop },
+ overlays: { syncMissionOverlayEntries: noop },
+ },
+ });
+ assert.equal(replay.startMissionReplay('mission', { resuming: true }), true);
+ assert.equal(track.lastCameraHeading, 1.2);
+});
diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js
index 77b3bd3d9..a34f6fd57 100644
--- a/src/layers/launches/selection.js
+++ b/src/layers/launches/selection.js
@@ -107,7 +107,11 @@ export function createSelection({
layerState._engine.camera.release();
}
- function startMissionZoomAnchor(launch, initialRange) {
+ function startMissionZoomAnchor(
+ launch,
+ initialRange,
+ { resuming = false } = {},
+ ) {
if (!layerState._engine || !launch || layerState._replayCameraLaunchId)
return;
stopMissionZoomAnchor();
@@ -118,6 +122,12 @@ export function createSelection({
);
layerState._missionZoomAnchorId = launch.id;
layerState._missionZoomRange = range;
+ if (resuming) {
+ layerState._engine.camera.lookAtTransform(
+ geo.transforms.eastNorthUpToFixedFrame(target),
+ );
+ return;
+ }
layerState._engine.camera.lookAt(target, {
heading: 0,
pitch: parts.camera.missionZoomPitch(range),
diff --git a/src/layers/launches/selection.test.mjs b/src/layers/launches/selection.test.mjs
new file mode 100644
index 000000000..d88952e23
--- /dev/null
+++ b/src/layers/launches/selection.test.mjs
@@ -0,0 +1,25 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
+import { createSelection } from './selection.js';
+
+test('restoring a mission zoom anchor preserves the world camera pose', (t) => {
+ const engine = createFakeEngine();
+ t.after(() => engine.destroy());
+ const state = { _engine: engine };
+ const selection = createSelection({
+ state,
+ services: { geometry: {} },
+ parts: { camera: { missionZoomPitch: () => -0.5 } },
+ });
+ const before = engine.camera.pose();
+ selection.startMissionZoomAnchor(
+ { id: 'mission', lon: 30, lat: 15 },
+ 200000,
+ { resuming: true },
+ );
+ const after = engine.camera.pose();
+ assert.deepEqual(after.position, before.position);
+ assert.equal(after.heading, before.heading);
+ assert.equal(after.pitch, before.pitch);
+});
diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js
index 58b146428..ee3aac7ee 100644
--- a/src/layers/launches/state.js
+++ b/src/layers/launches/state.js
@@ -135,6 +135,7 @@ export function createState({ services }) {
state._replaySpeed = 1;
state._replayTracks = new Map();
+ state._replayCameraHeading = null;
state._missionOverlayRecords = new Map();
diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js
index c41dc6b0c..94d43d6c9 100644
--- a/src/layers/localAdsb/models.js
+++ b/src/layers/localAdsb/models.js
@@ -159,7 +159,7 @@ export function createLocalAdsbModels({
view.engine,
id,
marker.position,
- () => [marker.entity, model].filter(Boolean),
+ () => [id],
);
if (!Number.isFinite(height)) return null;
const carto = geo.cartographic.fromCartesian(
diff --git a/src/layers/localAdsb/models.test.mjs b/src/layers/localAdsb/models.test.mjs
new file mode 100644
index 000000000..31734e54e
--- /dev/null
+++ b/src/layers/localAdsb/models.test.mjs
@@ -0,0 +1,50 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as geo from '../../geo/index.js';
+import { createLocalAdsbModels } from './models.js';
+
+test('ground snapping excludes the aircraft by its engine pick id', async () => {
+ const position = geo.cartesian3.fromDegrees(0, 0, 0);
+ const model = { ready: true };
+ const renderer = {
+ models: {
+ loadFleet: async () => model,
+ add() {},
+ remove() {},
+ destroy() {},
+ setPose() {},
+ },
+ view: {
+ camera: {
+ positionWC: geo.cartesian3.fromDegrees(0, 0, 100),
+ positionCartographic: { height: 100 },
+ },
+ frustumTester: () => () => true,
+ horizon: () => ({ isPointVisible: () => true }),
+ engine: {},
+ },
+ };
+ const exclusions = [];
+ const models = createLocalAdsbModels({
+ renderer,
+ color: {},
+ resolveAsset: (url) => url,
+ groundSnap: {
+ heightFor(engine, id, position, exclude) {
+ exclusions.push(exclude());
+ return 0;
+ },
+ },
+ });
+ const markers = new Map([
+ [
+ 'local-adsb:abc123',
+ { position, entity: {}, klass: 'airliner', onGround: true },
+ ],
+ ]);
+ models.frame(markers, { models3d: true }, 1000);
+ await new Promise(setImmediate);
+ models.frame(markers, { models3d: true }, 1100);
+ assert.deepEqual(exclusions, [['local-adsb:abc123']]);
+ models.destroy();
+});
diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js
index 3562a5385..67ba8b28b 100644
--- a/src/layers/military/lifecycle.js
+++ b/src/layers/military/lifecycle.js
@@ -1,13 +1,6 @@
import { createAircraftSceneBinding } from '../aircraft/sceneBinding.js';
-import { JET_MODEL_URL } from './policy.js';
-export function createLifecycle({
- flightState,
- services,
- parts,
- layer,
- resolveAsset,
-}) {
+export function createLifecycle({ flightState, services, parts, layer }) {
const { clearFocusTarget } = services.focus;
const { holdContinuousRender, releaseContinuousRender } = services.render;
const { ensureGeoidReady } = services.geoid;
@@ -19,7 +12,6 @@ export function createLifecycle({
flightState,
services,
parts,
- resolveModel: () => resolveAsset(JET_MODEL_URL),
enable: () => methods.enable(),
});
const methods = {
diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js
index 69bab83d2..ccd10c6e7 100644
--- a/src/layers/military/queries.js
+++ b/src/layers/military/queries.js
@@ -673,9 +673,7 @@ export function createQueries({
// follow): asking to track it again follows it again (as flights).
const entity = flightState._trackedEntity;
if (entity && !flightState._renderer?.follow.isFollowing(entity)) {
- flightState._trackedCameraFrameStop?.();
- flightState._trackedCameraFrameStop =
- flightState._renderer.follow.refocus(entity);
+ flightState._renderer.follow.refocus(entity);
}
return parts.tracking._publishTrackedSelection(id, origin);
}
@@ -750,9 +748,7 @@ export function createQueries({
!flightState._renderer.follow.isFollowing(flightState._trackedEntity)
)
return false;
- flightState._trackedCameraFrameStop?.();
- flightState._trackedCameraFrameStop =
- flightState._renderer.follow.refocus(flightState._trackedEntity);
+ flightState._renderer.follow.refocus(flightState._trackedEntity);
parts.tracking._publishTrackedSelection(id, origin);
return true;
},
diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js
index 8c937dce9..81a732947 100644
--- a/src/layers/military/rendering.js
+++ b/src/layers/military/rendering.js
@@ -530,7 +530,6 @@ export function createRendering({
model.show = false;
flightState._renderer.models.add(model);
flightState._models.set(icao24, model);
- flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard
}
/** Remove the 3D model for ONE aircraft (removal / track handoff).
@@ -697,13 +696,7 @@ export function createRendering({
}
flightState._trackedModel = m;
flightState._trackedModelLoading = false;
- // A good load retires this selection's failure budget — a contact that
- // recovers after a transient blip is not one attempt from giving up.
- flightState._trackedModelFailIcao = null;
- flightState._trackedModelFailCount = 0;
- flightState._trackedModelRetryAtMs = 0;
flightState._renderer.models.add(m);
- flightState._planeModelLoaded = true;
})
.catch((err) => {
if (gen !== flightState._trackedModelGen) return; // superseded load — not this selection's failure
@@ -712,6 +705,17 @@ export function createRendering({
});
return;
}
+ if (flightState._trackedModel?.failed) {
+ // The engine could not load the glTF: the billboard stays the visual
+ // and the load is tried again after the backoff.
+ const { url } = flightState._trackedModel.spec ?? {};
+ _releaseTrackedModel();
+ parts.tracking._noteTrackedModelLoadFailure(
+ url,
+ new Error('model failed to load'),
+ );
+ return;
+ }
if (flightState._trackedModel) {
// Keep the transform current while GPU resources are still loading. Cesium
// can flip ready during scene update after this callback; waiting for ready
@@ -735,6 +739,13 @@ export function createRendering({
spec.headingOffsetDeg,
);
if (!flightState._trackedModel.ready) return;
+ // A model that loaded retires this selection's failure budget: a contact
+ // that recovers after a transient blip is not one attempt from giving up.
+ if (flightState._trackedModelFailIcao === flightState._trackedIcao) {
+ flightState._trackedModelFailIcao = null;
+ flightState._trackedModelFailCount = 0;
+ flightState._trackedModelRetryAtMs = 0;
+ }
const view = flightState._renderer.view;
flightState._trackedModel.scale = trackedModelScaleForPixelCap({
baseScale: spec.scale,
diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js
index 738374c45..5cbabe436 100644
--- a/src/layers/military/snapshotRenderer.js
+++ b/src/layers/military/snapshotRenderer.js
@@ -31,6 +31,17 @@ export function createMilitarySnapshotRenderer({
groundFloor;
const { sampleMeshFloorCells } = meshFloor;
const { registerMilitaryIcaos } = militaryRegistry;
+ // A low contact is horizon-culled from 12 m up: its ground can lie below
+ // the ellipsoid.
+ function setCullPosition(id, meta) {
+ flightState._cullPositions.set(
+ id,
+ meta.renderAltitudeM < 10
+ ? geo.cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12)
+ : null,
+ );
+ }
+
function presentContact(icao24, meta, position, prevMeta, groundFlipped) {
const isTracked = icao24 === flightState._trackedIcao;
if (flightState._billboards.has(icao24)) {
@@ -98,6 +109,7 @@ export function createMilitarySnapshotRenderer({
meta.rawLat,
meta.renderAltitudeM,
);
+ setCullPosition(id, meta);
presentContact(id, meta, position, null, false);
}
}
@@ -130,12 +142,7 @@ export function createMilitarySnapshotRenderer({
meta.renderAltitudeM,
);
if (groundFlipped) flightState._groundSnap.forget(icao24);
- flightState._cullPositions.set(
- icao24,
- meta.renderAltitudeM < 10
- ? geo.cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12)
- : null,
- );
+ setCullPosition(icao24, meta);
const isTracked = icao24 === flightState._trackedIcao;
// Append to position history stamped with the FEED's fix epoch.
diff --git a/src/layers/military/state.js b/src/layers/military/state.js
index 9dac0abd0..d04cf921a 100644
--- a/src/layers/military/state.js
+++ b/src/layers/military/state.js
@@ -70,18 +70,6 @@ export function createFlightState({ source, services }) {
flightState._lastModelCapWarnMs = 0;
// throttle the "more planes in view than the cap" console notice
- // The tracked entity's billboard goes transparent (rather than hidden) once the model takes
- // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its
- // alpha after the GLB is preloaded so the model is ready to render the instant the billboard
- // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not
- // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation.
-
- flightState._planeModelLoaded = false;
-
- /** @type {object|null} retained preload that keeps the glTF cache warm */
-
- flightState._preloadModel = null;
-
/** Last limb taper per billboard, retained across class/ground/cockpit repaints. */
flightState._billboardLimbScale = new WeakMap();
@@ -116,10 +104,6 @@ export function createFlightState({ source, services }) {
flightState._trackedEntity = null;
- /** Disposes the single active tracked-camera framing owner. */
-
- flightState._trackedCameraFrameStop = null;
-
/** @type {object|null} Standalone 3D model for the tracked aircraft — NOT a graphic on
* _trackedEntity, so viewer.trackedEntity's follow-camera always has a ready bounding sphere
* (a model graphic reports PENDING until loaded, which freezes the centering on 3D-toggle). */
diff --git a/src/layers/military/testing.js b/src/layers/military/testing.js
index 5344dd57e..761db81d7 100644
--- a/src/layers/military/testing.js
+++ b/src/layers/military/testing.js
@@ -152,6 +152,20 @@ export function createTesting({
await parts.rendering._ensureModel(icao24);
return flightState._models.get(icao24) || null;
}
+
+ /** The renderer handles one contact draws with, for QA probes that tamper
+ * with them: its glyph and fleet model, and the tracked glyph and model
+ * while it is tracked. */
+
+ function _aircraftHandlesForTest(icao24) {
+ const tracked = icao24 === flightState._trackedIcao;
+ return {
+ sprite: flightState._billboards.get(icao24) ?? null,
+ model: flightState._models.get(icao24) ?? null,
+ trackedSprite: tracked ? flightState._trackedEntity : null,
+ trackedModel: tracked ? flightState._trackedModel : null,
+ };
+ }
return {
_setTrackedMilitaryRefreshStateForTest,
_setMilitaryTrackingRefreshOutcomeForTest,
@@ -164,5 +178,6 @@ export function createTesting({
_trackedBillboardColorForTest,
_driveFleetModelHandoffForTest,
_ensureFleetModelForTest,
+ _aircraftHandlesForTest,
};
}
diff --git a/src/layers/osmPlaces/render/display.js b/src/layers/osmPlaces/render/display.js
index 32913b2a5..73c90c268 100644
--- a/src/layers/osmPlaces/render/display.js
+++ b/src/layers/osmPlaces/render/display.js
@@ -2,8 +2,7 @@
// engine.surface, engine.camera), the same on every engine: 9 px discs in
// the place colour with a dark 2 px outline, on top only within 2.5 km of
// the camera. The contract has no ground clamping, so each point sits on the
-// sampled surface; heights are read again after each camera move, and
-// shortly after a draw that found no surface under a place yet.
+// sampled surface; visible points refine after camera moves and tile loads.
import * as geo from '../../../geo/index.js';
import { rgbaFromCss } from '../../shared/color.js';
@@ -16,6 +15,8 @@ const INITIAL_CAPACITY = 256;
// While the surface under a place has not loaded, its height is asked again.
const RESAMPLE_MS = 500;
const RESAMPLE_TRIES = 20;
+const SAMPLES_PER_PASS = 16;
+const SAMPLE_BUDGET_MS = 4;
// Under the moving contacts (src/data/spriteOrder.js), as map data.
const POINT_ORDER = -1;
@@ -34,7 +35,8 @@ export function createOsmPlacesDisplay(engine) {
records = [],
destroyed = false,
retry = null,
- tries = 0;
+ revision = 0;
+ const heights = new Map();
function ensure(count) {
if (set && count <= capacity) return;
set?.destroy();
@@ -56,21 +58,73 @@ export function createOsmPlacesDisplay(engine) {
const shown = visible ? records : [];
ensure(shown.length);
const sampling = engine.surface.supported;
- let unsampled = false;
+ let sampleTime = 0;
+ const now = Date.now();
+ const size = engine.host.size();
+ let samples = 0;
+ let delay = Infinity;
shown.forEach((record, index) => {
- const sampled = sampling
- ? engine.surface.sampleHeight(
+ let cached = heights.get(record);
+ if (!cached || cached.revision !== revision) {
+ cached = {
+ height: cached?.height ?? 0,
+ revision,
+ attempts: 0,
+ nextAt: 0,
+ resolved: false,
+ };
+ heights.set(record, cached);
+ }
+ let position = geo.cartesian3.fromDegrees(
+ record.lon,
+ record.lat,
+ cached.height + HEIGHT_OFFSET_M,
+ );
+ const screen = engine.projector.horizonVisible(position)
+ ? engine.projector.worldToWindow(position)
+ : null;
+ const inView =
+ screen &&
+ screen.x >= 0 &&
+ screen.y >= 0 &&
+ screen.x < size.width &&
+ screen.y < size.height;
+ if (
+ sampling &&
+ inView &&
+ !cached.resolved &&
+ cached.attempts < RESAMPLE_TRIES
+ ) {
+ if (
+ now >= cached.nextAt &&
+ samples < SAMPLES_PER_PASS &&
+ sampleTime < SAMPLE_BUDGET_MS
+ ) {
+ const started = performance.now();
+ const sampled = engine.surface.sampleHeight(
geo.cartographic.fromDegrees(record.lon, record.lat),
[set],
- )
- : undefined;
- if (!Number.isFinite(sampled)) unsampled = true;
+ );
+ sampleTime += performance.now() - started;
+ samples++;
+ cached.attempts++;
+ cached.nextAt = now + RESAMPLE_MS;
+ // Cesium samples kilometres underground while tiles stream.
+ if (Number.isFinite(sampled) && sampled >= -500 && sampled <= 9000) {
+ cached.height = sampled;
+ cached.resolved = true;
+ position = geo.cartesian3.fromDegrees(
+ record.lon,
+ record.lat,
+ sampled + HEIGHT_OFFSET_M,
+ );
+ }
+ }
+ if (!cached.resolved && cached.attempts < RESAMPLE_TRIES)
+ delay = Math.min(delay, Math.max(0, cached.nextAt - now));
+ }
set.set(index, {
- position: geo.cartesian3.fromDegrees(
- record.lon,
- record.lat,
- (Number.isFinite(sampled) ? sampled : 0) + HEIGHT_OFFSET_M,
- ),
+ position,
color,
show: true,
pickId: osmPlacePickId(record),
@@ -78,12 +132,18 @@ export function createOsmPlacesDisplay(engine) {
});
set.setCount(shown.length);
set.commit();
- if (unsampled && sampling && tries++ < RESAMPLE_TRIES)
- retry = setTimeout(draw, RESAMPLE_MS);
+ if (Number.isFinite(delay)) retry = setTimeout(draw, delay);
}
- const removeMoveEnd = engine.camera.moveEnd.addEventListener(() => {
- tries = 0;
+ function refine() {
+ revision++;
if (visible && records.length) draw();
+ }
+ const removeMoveEnd = engine.camera.moveEnd.addEventListener(refine);
+ let tilesLoaded = engine.surface.tilesLoaded();
+ const removeSurface = engine.frames.preRender.addEventListener(() => {
+ const loaded = engine.surface.tilesLoaded();
+ if (loaded && !tilesLoaded) refine();
+ tilesLoaded = loaded;
});
return {
setVisible(show) {
@@ -93,7 +153,8 @@ export function createOsmPlacesDisplay(engine) {
},
replace(next) {
records = next.slice();
- tries = 0;
+ heights.clear();
+ revision++;
draw();
},
destroy() {
@@ -101,6 +162,8 @@ export function createOsmPlacesDisplay(engine) {
destroyed = true;
clearTimeout(retry);
removeMoveEnd();
+ removeSurface();
+ heights.clear();
set?.destroy();
set = null;
records = [];
diff --git a/src/layers/osmPlaces/render/display.test.mjs b/src/layers/osmPlaces/render/display.test.mjs
index 4759b8a91..2c6a11207 100644
--- a/src/layers/osmPlaces/render/display.test.mjs
+++ b/src/layers/osmPlaces/render/display.test.mjs
@@ -15,7 +15,11 @@ const places = [
];
function fakeHarness() {
- const engine = createFakeEngine();
+ const engine = createFakeEngine({
+ longitude: geo.math.toRadians(85.31),
+ latitude: geo.math.toRadians(27.7),
+ altitude: 20000,
+ });
return {
name: 'the fake engine',
engine,
@@ -75,7 +79,7 @@ for (const makeHarness of HARNESSES) {
});
test(`places sit 2 m over the sampled surface, read again after a move (${name})`, (t) => {
- t.mock.timers.enable({ apis: ['setTimeout'] });
+ t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const h = makeHarness();
const display = createOsmPlacesDisplay(h.engine);
const position = () =>
@@ -139,3 +143,51 @@ for (const makeHarness of HARNESSES) {
}
});
}
+
+test('places refine loaded heights after tiles settle', (t) => {
+ const engine = createFakeEngine();
+ t.after(() => engine.destroy());
+ engine.projector.worldToWindow = () => ({ x: 100, y: 100 });
+ engine.projector.horizonVisible = () => true;
+ let height = 50;
+ let loaded = false;
+ engine.fake.script('sampleHeight', () => height);
+ engine.fake.script('tilesLoaded', () => loaded);
+ const display = createOsmPlacesDisplay(engine);
+ t.after(() => display.destroy());
+ display.setVisible(true);
+ display.replace([{ id: 'place', lon: 0, lat: 0 }]);
+ engine.fake.frames(1);
+ height = 70;
+ loaded = true;
+ engine.fake.frames(1);
+ const { positions } = engine.fake.moverSet('osm-places').store;
+ assert.ok(
+ Math.abs(
+ heightOf({ x: positions[0], y: positions[1], z: positions[2] }) - 72,
+ ) < 0.01,
+ );
+});
+
+test('large searches only sample visible places within a per-pass budget', (t) => {
+ const engine = createFakeEngine();
+ t.after(() => engine.destroy());
+ engine.projector.worldToWindow = () => ({ x: 100, y: 100 });
+ engine.projector.horizonVisible = () => true;
+ let samples = 0;
+ engine.fake.script('sampleHeight', () => {
+ samples++;
+ return 30;
+ });
+ const display = createOsmPlacesDisplay(engine);
+ t.after(() => display.destroy());
+ display.setVisible(true);
+ display.replace(
+ Array.from({ length: 500 }, (_, i) => ({ id: String(i), lon: 0, lat: 0 })),
+ );
+ assert.ok(samples > 0 && samples <= 16, `sampled ${samples}`);
+ engine.projector.worldToWindow = () => undefined;
+ samples = 0;
+ engine.camera.moveEnd.raiseEvent();
+ assert.equal(samples, 0, 'off-screen places are not sampled');
+});
diff --git a/src/layers/recentImagery/detachedGetStats.test.mjs b/src/layers/recentImagery/detachedGetStats.test.mjs
new file mode 100644
index 000000000..ae0af3e9b
--- /dev/null
+++ b/src/layers/recentImagery/detachedGetStats.test.mjs
@@ -0,0 +1,46 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
+import { createRecentImageryLayer } from './index.js';
+import {
+ fakeCatalog,
+ fakeRenderer,
+ fakeThumbnails,
+ manualTimers,
+ BOX,
+ CANDIDATES,
+ settle,
+} from './testDoubles.mjs';
+
+test('getStats between detachRenderer and attachRenderer does not throw', async () => {
+ const engine = createFakeEngine();
+ engine.camera.viewRectangle = () => undefined;
+ const timers = manualTimers();
+ const catalog = fakeCatalog();
+ const rendererFactory = () => {
+ const r = fakeRenderer();
+ r.resolveHost = () => ({ collection: {}, kind: 'globe' });
+ r.viewCentre = () => undefined;
+ r.zoomToFit = () => {};
+ return r;
+ };
+ const layer = createRecentImageryLayer({
+ catalog,
+ rendererFactory,
+ thumbnails: fakeThumbnails(),
+ now: () => new Date('2026-09-21T15:00:00Z'),
+ setTimeoutImpl: timers.setTimeoutImpl,
+ clearTimeoutImpl: timers.clearTimeoutImpl,
+ });
+ layer.init(null, { engine });
+ layer.enable();
+ layer.setBox(BOX);
+ catalog.resolveLast(CANDIDATES);
+ await settle();
+ assert.doesNotThrow(() => layer.getStats(), 'attached');
+ await layer.detachRenderer();
+ assert.doesNotThrow(
+ () => layer.getStats(),
+ 'detached: stats poll during an engine switch',
+ );
+});
diff --git a/src/layers/recentImagery/index.js b/src/layers/recentImagery/index.js
index 5646a661a..0aa5fa562 100644
--- a/src/layers/recentImagery/index.js
+++ b/src/layers/recentImagery/index.js
@@ -343,6 +343,7 @@ export function createRecentImageryLayer({
}
function syncHost() {
+ if (!renderer) return false;
const next = renderer.resolveHost();
const changed =
(next.identity || next.collection) !==
diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js
index 2337bac1c..137935b36 100644
--- a/src/layers/satellites/controls.js
+++ b/src/layers/satellites/controls.js
@@ -422,10 +422,11 @@ export function createControls({ state: layerState, services, parts, source }) {
if (params.showOrbits !== undefined) {
layerState._params.showOrbits = params.showOrbits !== false;
for (const path of layerState._orbitPaths.values())
- path.primitive.show = satelliteVisualsVisible(
- layerState._enabled,
- layerState._params.showOrbits,
- );
+ if (path.primitive)
+ path.primitive.show = satelliteVisualsVisible(
+ layerState._enabled,
+ layerState._params.showOrbits,
+ );
parts.labels._syncIssOverlay();
}
if (catalogChanged && catalog === 'dense') {
diff --git a/src/layers/satellites/detachedToggle.test.mjs b/src/layers/satellites/detachedToggle.test.mjs
new file mode 100644
index 000000000..3c394cd88
--- /dev/null
+++ b/src/layers/satellites/detachedToggle.test.mjs
@@ -0,0 +1,98 @@
+// Disabling Satellites, or changing showOrbits, between detachRenderer and
+// attachRenderer (an engine switch) must not throw on the dropped orbit rings.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
+import { createApplicationSatellites } from '../../app/layers/satellites.js';
+import { createSatellitesDisplay } from './render/display.js';
+import { appClock } from '../../time/clock.js';
+import { createLifecycle } from './lifecycle.js';
+
+const ISS_TLE = `ISS (ZARYA)
+1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927
+2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537`;
+const EPOCH_MS = Date.UTC(2008, 8, 20, 12, 30, 0);
+
+async function setup(t) {
+ const previousDocument = globalThis.document;
+ globalThis.document = Object.assign(new EventTarget(), {
+ getElementById: () => null,
+ });
+ appClock.freeze(EPOCH_MS);
+ const layer = createApplicationSatellites({
+ source: {
+ async readGroup(group) {
+ return {
+ ok: true,
+ status: 200,
+ text: group === 'stations' ? ISS_TLE : '',
+ stale: false,
+ };
+ },
+ },
+ renderer: { create: ({ engine }) => createSatellitesDisplay(engine) },
+ });
+ t.after(() => {
+ try {
+ layer.destroy();
+ } catch {}
+ appClock.reset();
+ globalThis.document = previousDocument;
+ });
+ await layer.init(null, { engine: createFakeEngine() });
+ layer.enable();
+ await layer.update();
+ return layer;
+}
+
+test('satellites: disable between detach and attach does not throw', async (t) => {
+ const layer = await setup(t);
+ layer.detachRenderer();
+ assert.doesNotThrow(() => layer.disable());
+});
+
+test('satellites: showOrbits between detach and attach does not throw', async (t) => {
+ const layer = await setup(t);
+ layer.detachRenderer();
+ assert.doesNotThrow(() => layer.setParams({ showOrbits: false }));
+});
+
+test('restored orbit rings receive their current rotation before the first frame', () => {
+ let rotated = 0;
+ const path = { basePositions: [], color: {} };
+ const state = {
+ _points: new Map(),
+ _orbitPaths: new Map([[25544, path]]),
+ _params: { showOrbits: true },
+ _enabled: true,
+ };
+ const lifecycle = createLifecycle({
+ state,
+ services: {
+ focus: {},
+ render: {},
+ picking: {},
+ renderer: {
+ create: () => ({
+ createPoints: () => ({}),
+ createOrbit: () => ({ modelMatrix: [] }),
+ }),
+ },
+ },
+ parts: {
+ rendering: {
+ _updateOrbitPathRotations(date) {
+ rotated++;
+ assert.ok(date instanceof Date);
+ assert.ok(path.primitive);
+ },
+ },
+ },
+ });
+ lifecycle.methods.attachRenderer(null, {
+ engine: {},
+ waitForCameraRestore: true,
+ });
+ assert.equal(rotated, 1);
+ assert.equal(path.primitive.show, true);
+});
diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js
index ab3482347..521fc5dee 100644
--- a/src/layers/satellites/lifecycle.js
+++ b/src/layers/satellites/lifecycle.js
@@ -1,3 +1,4 @@
+import { appClock } from '../../time/clock.js';
import { ISS_NORAD, ISS_OVERLAY_SOURCE_ID } from './policy.js';
export function createLifecycle({
@@ -162,6 +163,7 @@ export function createLifecycle({
path.primitive.show =
layerState._enabled && layerState._params.showOrbits;
}
+ parts.rendering._updateOrbitPathRotations(new Date(appClock.now()));
// Source work can publish as soon as GPU collections exist. Restoring
// share-selected camera state may itself wait for that publication.
layerState._resumeRenderer?.();
@@ -197,10 +199,11 @@ export function createLifecycle({
);
// Orbit ring primitives + persistent ISS host label — show them
for (const path of layerState._orbitPaths.values())
- path.primitive.show = parts.controls.satelliteVisualsVisible(
- layerState._enabled,
- layerState._params.showOrbits,
- );
+ if (path.primitive)
+ path.primitive.show = parts.controls.satelliteVisualsVisible(
+ layerState._enabled,
+ layerState._params.showOrbits,
+ );
parts.labels._syncIssOverlay();
// Re-attach input handlers and preRender propagation
if (layerState._renderer) parts.interaction._installClickHandler();
@@ -229,7 +232,7 @@ export function createLifecycle({
releaseContinuousRender('satellites', layerState._engine);
if (layerState._pointCollection) layerState._pointCollection.show = false;
for (const path of layerState._orbitPaths.values())
- path.primitive.show = false;
+ if (path.primitive) path.primitive.show = false;
parts.tracking._clearTracking();
parts.labels._syncIssOverlay();
// Remove click handler + keydown listener + preRender propagation while disabled
diff --git a/src/layers/shared/render/moverBank.js b/src/layers/shared/render/moverBank.js
index 91ec5038b..e279511f8 100644
--- a/src/layers/shared/render/moverBank.js
+++ b/src/layers/shared/render/moverBank.js
@@ -89,6 +89,10 @@ export function createMoverBank(engine, { options, write, onCommit }) {
bank.touch();
},
visible: () => visible,
+ /** The same handles in a new set (one that loads its assets again). */
+ rebuild() {
+ if (set) build(capacity);
+ },
touch() {
if (!dirty) {
dirty = true;
diff --git a/src/layers/shared/render/surfacePoints.js b/src/layers/shared/render/surfacePoints.js
new file mode 100644
index 000000000..ed44db022
--- /dev/null
+++ b/src/layers/shared/render/surfacePoints.js
@@ -0,0 +1,127 @@
+// Stands map points on the rendered surface (tiles, else terrain), the same
+// on every engine: the contract has no ground clamp. On the globe that
+// surface is the terrain, read from its loaded tiles; only 3D tiles, which
+// hide the globe, need sampleHeight, a pick render on Cesium. Only points
+// on screen are measured, a few per pass, as a layer can hold thousands.
+// They are measured again after each camera move and once the surface's
+// tiles have loaded; a point whose height holds is not written again, so a
+// parked camera asks for no frames.
+
+import * as geo from '../../../geo/index.js';
+
+const SAMPLES_PER_PASS = 16;
+const SAMPLE_BUDGET_MS = 4;
+const HEIGHT_EPSILON_M = 0.01;
+// A point not placed yet projects from the ellipsoid, away from where it
+// will stand: the screen test takes this much of the view around it too.
+const SCREEN_MARGIN = 0.25;
+
+// Dead Sea to Everest, with geoid slack: Cesium samples kilometres
+// underground while tiles stream.
+const plausible = (height) =>
+ Number.isFinite(height) && height >= -500 && height <= 9000;
+
+/**
+ * @param {import('../../../globe/contract.js').GlobeEngine} engine
+ * @param {object} options
+ * @param {() => Iterable<{ set: object | null, points: object[], exclude?: object[] }>} options.groups
+ * Mover sets with the points they draw, point i as instance i, and what
+ * the samples skip (the set by default; a layer adds the rest it draws
+ * over the points). A point has `cartographic` and `position`, which a
+ * placement replaces (keeping the height in `surfaceHeight`); one with
+ * `show === false` is skipped.
+ * @param {() => void} options.request Asks for a frame after a placement.
+ * @returns {{ place: () => void, destroy: () => void }} `place()` measures
+ * the points not yet placed on this surface (new points, for example).
+ */
+export function createSurfacePoints(engine, { groups, request }) {
+ let revision = 0;
+ let timer = null;
+ let destroyed = false;
+ let tilesLoaded = engine.surface.tilesLoaded();
+
+ function onScreen(position, size) {
+ const screen = engine.projector.worldToWindow(position);
+ const x = size.width * SCREEN_MARGIN;
+ const y = size.height * SCREEN_MARGIN;
+ return (
+ screen &&
+ screen.x >= -x &&
+ screen.y >= -y &&
+ screen.x < size.width + x &&
+ screen.y < size.height + y
+ );
+ }
+
+ // Points off screen are passed over until the next move or tile load.
+ function place() {
+ clearTimeout(timer);
+ timer = null;
+ if (destroyed) return;
+ const size = engine.host.size();
+ const tiles = engine.maps?.photorealActive?.() === true;
+ const started = performance.now();
+ let samples = 0;
+ let moved = false;
+ for (const { set, points, exclude = [set] } of groups()) {
+ if (!set) continue;
+ let changed = false;
+ for (let index = 0; index < points.length; index++) {
+ const point = points[index];
+ if (point.surfaceRevision === revision) continue;
+ if (
+ samples >= SAMPLES_PER_PASS ||
+ performance.now() - started >= SAMPLE_BUDGET_MS
+ ) {
+ if (changed) set.commit();
+ if (changed || moved) request();
+ timer = setTimeout(place, 0);
+ return;
+ }
+ point.surfaceRevision = revision;
+ if (point.show === false || !onScreen(point.position, size)) continue;
+ samples++;
+ let height = tiles
+ ? engine.surface.sampleHeight(point.cartographic, exclude)
+ : undefined;
+ if (!plausible(height))
+ height = engine.surface.terrainHeight(point.cartographic);
+ if (!plausible(height)) continue;
+ if (Math.abs(height - point.surfaceHeight) < HEIGHT_EPSILON_M) continue;
+ point.surfaceHeight = height;
+ point.position = geo.cartesian3.fromRadians(
+ point.cartographic.longitude,
+ point.cartographic.latitude,
+ height,
+ );
+ set.set(index, { position: point.position });
+ changed = true;
+ }
+ if (changed) set.commit();
+ moved ||= changed;
+ }
+ if (moved) request();
+ }
+
+ function refine() {
+ revision++;
+ place();
+ }
+ const removeMoveEnd = engine.camera.moveEnd.addEventListener(refine);
+ const removeFrame = engine.frames.preRender.addEventListener(() => {
+ const loaded = engine.surface.tilesLoaded();
+ if (loaded && !tilesLoaded) refine();
+ tilesLoaded = loaded;
+ });
+
+ return {
+ place,
+ destroy() {
+ if (destroyed) return;
+ destroyed = true;
+ clearTimeout(timer);
+ removeMoveEnd();
+ removeFrame();
+ },
+ };
+}
diff --git a/src/layers/shared/render/surfacePoints.test.mjs b/src/layers/shared/render/surfacePoints.test.mjs
new file mode 100644
index 000000000..d04f254a2
--- /dev/null
+++ b/src/layers/shared/render/surfacePoints.test.mjs
@@ -0,0 +1,75 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as geo from '../../../geo/index.js';
+import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
+import { createSurfacePoints } from './surfacePoints.js';
+
+function setup(t, count = 3) {
+ const engine = createFakeEngine();
+ t.after(() => engine.destroy());
+ engine.projector.worldToWindow = () => ({ x: 100, y: 100 });
+ const set = engine.movers.createSet({
+ id: 'points',
+ kind: 'point',
+ capacity: count,
+ point: { sizePx: 6 },
+ });
+ const points = Array.from({ length: count }, (_, index) => ({
+ cartographic: geo.cartographic.fromDegrees(index, 0),
+ position: geo.cartesian3.fromDegrees(index, 0),
+ }));
+ points.forEach((point, index) =>
+ set.set(index, { position: point.position, show: true }),
+ );
+ set.setCount(count);
+ set.commit();
+ const surface = createSurfacePoints(engine, {
+ groups: () => [{ set, points }],
+ request: () => engine.render.request('points'),
+ });
+ t.after(() => surface.destroy());
+ const heights = () =>
+ points.map(
+ (point) => geo.cartographic.fromCartesian(point.position).height,
+ );
+ return { engine, surface, heights };
+}
+
+test('on the globe points stand on the terrain without a pick sample', (t) => {
+ const { engine, surface, heights } = setup(t);
+ let picks = 0;
+ engine.fake.script('sampleHeight', () => {
+ picks++;
+ return 900;
+ });
+ engine.fake.script('terrainHeight', () => 40);
+ surface.place();
+ assert.equal(picks, 0);
+ for (const height of heights()) assert.ok(Math.abs(height - 40) < 0.01);
+});
+
+test('under 3D tiles points stand on the sampled tiles', async (t) => {
+ const { engine, surface, heights } = setup(t);
+ await engine.maps.setPhotorealTiles({ id: 'photoreal' });
+ engine.fake.script('sampleHeight', () => 55);
+ engine.fake.script('terrainHeight', () => 40);
+ surface.place();
+ for (const height of heights()) assert.ok(Math.abs(height - 55) < 0.01);
+});
+
+test('a parked camera asks for no frames once the points stand', (t) => {
+ const { engine, heights } = setup(t);
+ let terrain = 40;
+ engine.fake.script('terrainHeight', () => terrain);
+ engine.fake.script('tilesLoaded', () => true);
+ engine.camera.moveEnd.raiseEvent();
+ assert.ok(Math.abs(heights()[0] - 40) < 0.01);
+ const before = engine.fake.renderRequests;
+ engine.camera.moveEnd.raiseEvent();
+ engine.fake.frames(3);
+ assert.equal(engine.fake.renderRequests, before, 'same heights, no frame');
+ terrain = 46;
+ engine.camera.moveEnd.raiseEvent();
+ assert.ok(engine.fake.renderRequests > before, 'a changed height draws');
+ assert.ok(Math.abs(heights()[0] - 46) < 0.01);
+});
diff --git a/src/layers/streetLevel/render/display.js b/src/layers/streetLevel/render/display.js
index f1345cfb7..efabbcfb5 100644
--- a/src/layers/streetLevel/render/display.js
+++ b/src/layers/streetLevel/render/display.js
@@ -179,14 +179,11 @@ export function createStreetLevelDisplay(engine) {
function paint() {
if (destroyed) return;
- // The surface settled (or the map stack changed): place every sprite
- // again on the globe, where terrain is cheap; under tiles only the
- // ones not on them yet.
+ // Loaded detail can replace the surface under an already placed sprite.
const loaded = engine.surface.tilesLoaded();
const nowTiles = tilesShown();
if ((loaded && settled === false) || nowTiles !== tiles) {
- for (const row of rows)
- if (!nowTiles || !row.exact || nowTiles !== tiles) place(row);
+ for (const row of rows) place(row);
dirty = true;
}
settled = loaded;
diff --git a/src/layers/streetLevel/render/display.test.mjs b/src/layers/streetLevel/render/display.test.mjs
index abc83138d..ed54250e9 100644
--- a/src/layers/streetLevel/render/display.test.mjs
+++ b/src/layers/streetLevel/render/display.test.mjs
@@ -199,13 +199,13 @@ test('a sprite sits on the terrain; under Google 3D on the tiles, placed again w
excluded.every((list) => Array.isArray(list)),
'its own sprites are left out of the sample',
);
- // On the tiles now: a later settle samples nothing again.
- const samples = excluded.length;
+ // A later settle can replace the coarse tiles under the sprite.
+ tiles = 45;
fake.script('tilesLoaded', false);
harness.frame();
fake.script('tilesLoaded', true);
harness.frame();
- assert.equal(excluded.length, samples);
+ assert.ok(Math.abs(heightOf(google.drawn().position) - 45) < 1e-6);
// An implausible sample (tiles still streaming) is not ground.
tiles = -14_886;
google.group.move(google.row, { lon: -121.4945 });
diff --git a/src/layers/submarineCables/render/display.js b/src/layers/submarineCables/render/display.js
index b3dbc3486..4bb99c10b 100644
--- a/src/layers/submarineCables/render/display.js
+++ b/src/layers/submarineCables/render/display.js
@@ -2,8 +2,8 @@ import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js';
// The submarine cables on the globe contract, the same on every engine:
// - cable paths: one engine.ground corridor set (2.5 px, each cable's own
// colour at 0.92), draped on the active map stack's surface;
-// - landing points: a point mover set at the ground (the 6 px GeoJSON pin's
-// width and colour);
+// - landing points: a point mover set on the sampled surface (the 6 px
+// GeoJSON pin's width and colour);
// - references: per cable and landing point, a tip point mover high enough
// for a ~66 px stem, sized on the sweep cadence, with its label on the
// shared world-overlay host;
@@ -14,6 +14,7 @@ import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js';
import * as geo from '../../../geo/index.js';
import { isPointerFree } from '../../../data/inputOwnership.js';
import { rgbaFromCss } from '../../shared/color.js';
+import { createSurfacePoints } from '../../shared/render/surfacePoints.js';
import {
featureLabel,
featureReference,
@@ -213,11 +214,10 @@ export function createCablesDisplay(
}
for (const set of tipSets.values()) set.commit();
if (markers) {
- markerRows.forEach((row, index) =>
- markers.set(index, {
- show: engine.projector.horizonVisible(row.base),
- }),
- );
+ markerRows.forEach((row, index) => {
+ row.show = engine.projector.horizonVisible(row.base);
+ markers.set(index, { show: row.show });
+ });
markers.commit();
}
const winners = selectCableReferenceLabelWinners([...records.values()]);
@@ -365,6 +365,7 @@ export function createCablesDisplay(
items.push({ positions, ...style.path(feature), pickId: id });
for (const point of shapes.points)
rows.push({
+ cartographic: geo.cartographic.fromDegrees(point[0], point[1]),
position: geo.cartesian3.fromDegrees(point[0], point[1]),
base,
color: style.marker,
@@ -380,7 +381,7 @@ export function createCablesDisplay(
id: `${ID}-markers`,
kind: 'point',
capacity: rows.length,
- depthTest: 'normal',
+ depthTest: { disableWithinM: 2500 },
pickable: true,
point: { sizePx: MARKER_PX, outlinePx: 0 },
});
@@ -457,7 +458,21 @@ export function createCablesDisplay(
try {
build(next);
dataset = next;
+ const surface = createSurfacePoints(engine, {
+ // The reference tips and stems stand over the landing points.
+ groups: () => [
+ {
+ set: markers,
+ points: markerRows,
+ exclude: [markers, stemBatch, ...tipSets.values()].filter(
+ Boolean,
+ ),
+ },
+ ],
+ request: wake,
+ });
removers.push(
+ surface.destroy,
engine.input.on('click', onClick, { pick: true }),
engine.camera.moveEnd.addEventListener(() => {
gate.markDirty();
@@ -465,6 +480,7 @@ export function createCablesDisplay(
}),
engine.frames.preRender.addEventListener(sweep),
);
+ surface.place();
publisher.show();
// A hide cleared the host source: an identical cohort republishes.
resetSignature();
diff --git a/src/layers/submarineCables/render/display.test.mjs b/src/layers/submarineCables/render/display.test.mjs
index 41246a545..d9d1b04a5 100644
--- a/src/layers/submarineCables/render/display.test.mjs
+++ b/src/layers/submarineCables/render/display.test.mjs
@@ -672,3 +672,53 @@ test('edge:telegeography-submarine-cables: a click on a cable beside its landing
`reference at ${geo.math.toDegrees(target.longitude)}°`,
);
});
+
+test('landing point markers stand on the coastal 3D tiles, pickable at close range', async (t) => {
+ const engine = createFakeEngine();
+ t.after(() => engine.destroy());
+ await engine.maps.setPhotorealTiles({ id: 'photoreal' });
+ engine.projector.worldToWindow = () => ({ x: 100, y: 100 });
+ let height = 40;
+ let loaded = false;
+ engine.fake.script('sampleHeight', () => height);
+ engine.fake.script('tilesLoaded', () => loaded);
+ const display = createCablesDisplay(engine, {
+ overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
+ });
+ t.after(() => display.destroy());
+ display.show({
+ cables: [],
+ landingPoints: {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'coast',
+ properties: {},
+ geometry: { type: 'Point', coordinates: [0, 0] },
+ },
+ ],
+ },
+ });
+ const { options } = engine.fake.moverSet(
+ `${CABLE_OVERLAY_SOURCE_ID}-markers`,
+ );
+ assert.deepEqual(options.depthTest, { disableWithinM: 2500 });
+ assert.equal(options.pickable, true);
+ const altitude = () => {
+ const { store } = engine.fake.moverSet(
+ `${CABLE_OVERLAY_SOURCE_ID}-markers`,
+ );
+ return geo.cartographic.fromCartesian({
+ x: store.positions[0],
+ y: store.positions[1],
+ z: store.positions[2],
+ }).height;
+ };
+ assert.ok(Math.abs(altitude() - 40) < 0.01, 'on the tiles, not under them');
+ engine.fake.frames(1);
+ height = 46;
+ loaded = true;
+ engine.fake.frames(1);
+ assert.ok(Math.abs(altitude() - 46) < 0.01, 'refined once tiles load');
+});
diff --git a/src/layers/traffic/render/belowEllipsoid.test.mjs b/src/layers/traffic/render/belowEllipsoid.test.mjs
new file mode 100644
index 000000000..ad3392f26
--- /dev/null
+++ b/src/layers/traffic/render/belowEllipsoid.test.mjs
@@ -0,0 +1,48 @@
+// Traffic on roads below the WGS84 ellipsoid (negative ellipsoidal heights:
+// southern India and Sri Lanka, Florida, the Dead Sea) stays drawn while the
+// camera is low above the road, as Cesium's PointPrimitiveCollection did on main.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as geo from '../../../geo/index.js';
+import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs';
+import { nearFarScale } from '../../shared/color.js';
+import { SIM_DOT_COLOR } from '../policy.js';
+import { TRAFFIC_SET_IDS as IDS, createTrafficDisplay } from './display.js';
+
+// Colombo: the ground stands about 93 m below the ellipsoid.
+const LON = 79.85;
+const LAT = 6.93;
+const GROUND_M = -93;
+
+for (const makeHarness of GROUND_HARNESSES) {
+ test(`a car on a road below the ellipsoid stays drawn under a low camera (${makeHarness().name})`, (t) => {
+ const h = makeHarness();
+ t.after(() => h.destroy());
+ // Camera 50 m above the road, looking at a car 500 m ahead.
+ h.engine.camera.setView({
+ destination: geo.cartesian3.fromDegrees(LON, LAT, GROUND_M + 50),
+ });
+ const display = createTrafficDisplay(h.engine);
+ t.after(() => display.destroy());
+ display.drawDots([
+ {
+ point: {
+ position: geo.cartesian3.fromDegrees(
+ LON,
+ LAT + 0.0045,
+ GROUND_M + 1.5,
+ ),
+ pixelSize: 5,
+ color: SIM_DOT_COLOR,
+ show: true,
+ scaleByDistance: nearFarScale(100, 1.5, 8000, 0.3),
+ translucencyByDistance: nearFarScale(100, 1, 10000, 0),
+ outlineColor: null,
+ outlineWidth: 0,
+ },
+ },
+ ]);
+ const { store } = h.engine.fake.moverSet(IDS.fill);
+ assert.equal(store.count, 1, 'the car is drawn');
+ });
+}
diff --git a/src/layers/traffic/render/display.js b/src/layers/traffic/render/display.js
index 4ece80163..9f73a41b9 100644
--- a/src/layers/traffic/render/display.js
+++ b/src/layers/traffic/render/display.js
@@ -101,7 +101,6 @@ export function createTrafficDisplay(engine) {
// A dot at size 0 (fading in, recycled at a road end) draws nothing,
// its outline included.
if (!point?.show || !(point.pixelSize > 0) || !point.position) continue;
- if (!engine.projector.horizonVisible(point.position)) continue;
const distance = geo.cartesian3.distance(point.position, camera);
const alpha = nearFarValue(point.translucencyByDistance, distance);
if (alpha < MIN_TRANSLUCENCY) continue;
diff --git a/src/layers/traffic/surface.js b/src/layers/traffic/surface.js
index 18482cc6e..f888f72ea 100644
--- a/src/layers/traffic/surface.js
+++ b/src/layers/traffic/surface.js
@@ -181,25 +181,32 @@ const surfaceId = (object) => {
/**
* Name what heights are sampled against: the photoreal tiles shown, or the
- * globe under its base imagery (the contract does not name the terrain; a map
- * stack switch replaces the base imagery with it). A map-provider switch
- * changes it and invalidates every cached height.
+ * globe under its base imagery and terrain (a terrain that loads after a
+ * failure keeps the imagery). A map-provider or terrain switch changes it and
+ * invalidates every cached height.
*/
const surfaceKeys = new WeakMap();
export function trafficSurfaceKey(engine) {
let state = surfaceKeys.get(engine);
if (!state) {
- state = { tiles: false, source: null, key: '' };
+ state = { tiles: false, source: null, terrain: 0, key: '' };
surfaceKeys.set(engine, state);
}
const tiles = engine.maps.photorealActive();
const current = engine.maps.current();
const source = tiles ? current.photoreal : current.baseImagery;
- if (state.tiles !== tiles || state.source !== source || !state.key) {
+ const terrain = tiles ? 0 : engine.maps.terrainRevision();
+ if (
+ state.tiles !== tiles ||
+ state.source !== source ||
+ state.terrain !== terrain ||
+ !state.key
+ ) {
state.tiles = tiles;
state.source = source;
+ state.terrain = terrain;
// Allocate only when the actual map surface changes, never per frame.
- state.key = `${tiles ? 'tiles' : 'globe'}:${surfaceId(source)}`;
+ state.key = `${tiles ? 'tiles' : `globe:${terrain}`}:${surfaceId(source)}`;
}
return state.key;
}
diff --git a/src/layers/traffic/surface.test.mjs b/src/layers/traffic/surface.test.mjs
index 1a8ebf205..21211619f 100644
--- a/src/layers/traffic/surface.test.mjs
+++ b/src/layers/traffic/surface.test.mjs
@@ -343,7 +343,7 @@ test('rendered road depth is geographically checked, bounded, and avoids mesh re
assert.equal(renderedRoadHeight(engine, -97, 30, 0), undefined);
});
-test('surface keys reuse identity without building arrays or strings on unchanged frames', () => {
+test('surface keys reuse identity without building arrays or strings on unchanged frames', async () => {
const engine = surfaceEngine();
const first = trafficSurfaceKey(engine);
const remove = observeTrafficSurface(engine);
@@ -386,6 +386,13 @@ test('surface keys reuse identity without building arrays or strings on unchange
globe,
'a map stack switch on the globe is a new surface',
);
+ const terrainBefore = trafficSurfaceKey(engine);
+ await engine.maps.setTerrain({ kind: 'ellipsoid' });
+ assert.notEqual(
+ trafficSurfaceKey(engine),
+ terrainBefore,
+ 'a terrain that loads under the same imagery is a new surface',
+ );
remove();
remove();
assert.equal(engine.frames.postRender.numberOfListeners, 0);
diff --git a/src/layers/transit/render/style.js b/src/layers/transit/render/style.js
index 542a9b04b..c385c66f1 100644
--- a/src/layers/transit/render/style.js
+++ b/src/layers/transit/render/style.js
@@ -1,5 +1,5 @@
// How the transit vehicles and the selected vehicle's trail draw
-// (engine.movers sprite sets, engine.lines).
+// (engine.movers sprite sets, engine.ground corridors).
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js
index a9e3681d3..a122f3ccd 100644
--- a/src/layers/vessels/lifecycle.js
+++ b/src/layers/vessels/lifecycle.js
@@ -287,8 +287,7 @@ export function createLifecycle({
}
function bindVisibleScene(activeViewer, scene) {
- // Pick-ownership (H2): vessel picks carry the record OBJECT as their id;
- // the registry resolver reduces it to the record's mmsi (a string key).
+ // Only MMSI picks present in the live record map belong to this layer.
registerPickOwner(
'ais-live-vessels',
(pickedId) => state.records.byMmsi.has(pickedId),
diff --git a/src/layers/weather/render/display.js b/src/layers/weather/render/display.js
index c26a12fce..277ce067f 100644
--- a/src/layers/weather/render/display.js
+++ b/src/layers/weather/render/display.js
@@ -578,6 +578,7 @@ export function createWeatherDisplay(
requestRender: () => engine.render.request('weather'),
viewRectangle: () => engine.camera.viewRectangle(),
moveEnd: engine.camera.moveEnd,
+ maxTextureSize: engine.render.maxTextureSize(),
createSurface: (frame) =>
createRaisedWeatherSurface(engine, product, frame),
});
diff --git a/src/layers/weather/shellRendering.js b/src/layers/weather/shellRendering.js
index 8e31ced97..a56fe37ad 100644
--- a/src/layers/weather/shellRendering.js
+++ b/src/layers/weather/shellRendering.js
@@ -90,12 +90,22 @@ export function detailWindow(footprint, bounds, previous = null) {
return previous && sameEdges(previous, next) ? previous : next;
}
+// Halves, down to 1024 px wide, on devices with a smaller texture limit.
+function fitTexture({ width, height }, limit) {
+ while (limit > 0 && width > limit && width > 1024) {
+ width /= 2;
+ height /= 2;
+ }
+ return { width, height };
+}
+
/** Observed weather on 3D Tiles: one raised shell per product and one
* full-extent image per frame. The previous image stays until the next is ready.
* Inside a window around the view the same surface samples a sharper image of
* that area, so the two images share one mesh and never blend. The surfaces
* are the engine's (`createSurface`, render/display.js), as are the render
- * requests, the view footprint and the camera's move-end event. */
+ * requests, the view footprint, the camera's move-end event and the GPU's
+ * texture limit. */
export function createWeatherShell({
product,
height = WEATHER_SHELL_HEIGHTS[product],
@@ -111,11 +121,12 @@ export function createWeatherShell({
requestRender,
viewRectangle,
moveEnd,
+ maxTextureSize = Infinity,
}) {
if (!Object.hasOwn(WEATHER_IMAGE_SIZES, product))
throw new TypeError('Unknown weather product');
- const size = { ...WEATHER_IMAGE_SIZES[product] };
- const detailSize = { ...WEATHER_DETAIL_SIZE };
+ const size = fitTexture(WEATHER_IMAGE_SIZES[product], maxTextureSize);
+ const detailSize = fitTexture(WEATHER_DETAIL_SIZE, maxTextureSize);
const infrared = product === 'clouds' || product === 'clouds-regional';
// Global infrared contrast depends on the requested extent, so a window would
// not match the image around it; the globe host also shows one mosaic.
diff --git a/src/layers/weather/shellRendering.test.mjs b/src/layers/weather/shellRendering.test.mjs
index 1503f9d7f..f94a8c1af 100644
--- a/src/layers/weather/shellRendering.test.mjs
+++ b/src/layers/weather/shellRendering.test.mjs
@@ -526,6 +526,21 @@ test('image size follows the product maximum', async () => {
}
});
+test('a GPU with a 2048 px texture limit gets 2048 px images', async () => {
+ const h = harness({
+ maxTextureSize: 2048,
+ decodeImage: async () => ({ width: 2048, height: 1024, close() {} }),
+ });
+ assert.deepEqual(h.shell.getDiagnostics().imageSize, {
+ width: 2048,
+ height: 1024,
+ });
+ assert.equal(await show(h, times[0]), true);
+ const url = new URL(h.fetches[0].url, 'https://example.test');
+ assert.equal(url.searchParams.get('size'), '2048x1024');
+ h.shell.clear();
+});
+
test('a host without imagery hides the shell, keeps the frame and refuses work', async () => {
const h = harness();
assert.equal(await show(h, times[0]), true);
diff --git a/src/main.js b/src/main.js
index 74d00a4ee..8c3573315 100644
--- a/src/main.js
+++ b/src/main.js
@@ -5,7 +5,12 @@ import {
import { createRendererCache } from './standalone/rendererCache.js';
import { describeError } from './standalone/errors.js';
import { createEngineAddress } from './standalone/engineSwitch.js';
-import { ENGINE_STORAGE_KEY, engineOfChoice } from './app/engineChoice.js';
+import {
+ DEFAULT_ENGINE_CHOICE,
+ ENGINE_STORAGE_KEY,
+ engineOfChoice,
+ resolveEngineKind,
+} from './app/engineChoice.js';
import { createSceneSwitcher } from './standalone/sceneSwitcher.js';
import { createLayerSession } from './app/layerSession.js';
import { createSessionDiagnostics } from './app/sessionDiagnostics.js';
@@ -73,7 +78,7 @@ window.addEventListener('pagehide', (event) => {
// settle() hands the address back to whichever engine then runs.
window.addEventListener('popstate', () => {
if (!engineSwitch) return;
- if (pageEngineKind() !== engineSwitch.state().engine)
+ if (requestedEngineKind() !== engineSwitch.state().engine)
application
?.getComponents()
.controls?.styleManager?.shareLinkManager?.holdNavigation?.();
@@ -120,6 +125,13 @@ function present() {
button.disabled = busy;
}
+// Product names: never translated.
+const ENGINE_NAMES = {
+ cesium: 'Cesium',
+ navara: 'Navara',
+ 'navara-hd': 'Navara HD',
+};
+
// No engine runs after a failed recovery. The error stays on the loading
// screen with an engine picker, so a new attempt needs no reload.
function offerEngineRetry() {
@@ -129,11 +141,7 @@ function offerEngineRetry() {
group.className = 'engine-switch-retry';
group.setAttribute('role', 'group');
group.setAttribute('aria-label', t('Globe Engine'));
- for (const [kind, name] of [
- ['cesium', 'Cesium'],
- ['navara', 'Navara'],
- ['navara-hd', 'Navara HD'],
- ]) {
+ for (const [kind, name] of Object.entries(ENGINE_NAMES)) {
const button = document.createElement('button');
button.type = 'button';
button.dataset.engine = kind;
@@ -157,8 +165,21 @@ function report(error) {
loading.classList.remove('hidden');
}
+// A globe that failed to start while another runs: say which one runs.
+function showStartFailure(error, running) {
+ const style = liveComponents()?.controls.styleManager;
+ style?._showToast(
+ t('Globe engine failed to start. Running {engine}: {message}', {
+ engine: ENGINE_NAMES[running] ?? running,
+ message: describeError(error),
+ }),
+ );
+ return Boolean(style);
+}
+
function select(next) {
- void engineSwitch?.select(next);
+ if (engineSwitch) void engineSwitch.select(next);
+ else if (!starting) void start(next, { rememberChoice: true });
}
// Keep the last visible frame while the first visit initializes the other renderer.
@@ -177,14 +198,32 @@ function keepGlobeFrame(engine) {
return frame;
}
-const initialKind = pageEngineKind();
-createApplication(initialKind, false)
- .start()
- .then(() => {
+let initialKind = pageEngineKind();
+let starting = false;
+const requestedEngineKind = () =>
+ resolveEngineKind({ search: location.search, fallback: initialKind });
+
+function remember(kind) {
+ try {
+ localStorage.setItem(ENGINE_STORAGE_KEY, kind);
+ } catch {
+ // The current URL still carries the choice when storage is blocked.
+ }
+}
+
+async function start(kind, { fallback = false, rememberChoice = false } = {}) {
+ starting = true;
+ try {
+ await createApplication(kind, false).start();
+ if (rememberChoice) {
+ initialKind = kind;
+ remember(kind);
+ createEngineAddress({ location, history }).restore(kind);
+ }
session.count('attachments');
engineSwitch = createSceneSwitcher({
- kind: initialKind,
- requested: pageEngineKind,
+ kind,
+ requested: requestedEngineKind,
signal,
getApplication: () => application,
startApplication: (kind, transition) =>
@@ -203,25 +242,36 @@ createApplication(initialKind, false)
flush: () =>
liveComponents()?.controls.styleManager.shareLinkManager?.flush(),
}),
- remember(kind) {
- try {
- localStorage.setItem(ENGINE_STORAGE_KEY, kind);
- } catch {
- // The current URL still carries the choice when storage is blocked.
- }
- },
+ remember,
onChange: present,
- onFailure(error, { recovered }) {
- const style = recovered && liveComponents()?.controls.styleManager;
- if (style) return style._showToast(describeError(error));
+ onFailure(error, { recovered, kind: running }) {
+ if (recovered && showStartFailure(error, running)) return;
report(error);
offerEngineRetry();
},
});
+ withdrawEngineRetry();
+ loading.classList.add('hidden');
present();
// A history visit made while the first engine started.
void engineSwitch.reconcile();
- })
- .catch(report);
+ } catch (error) {
+ await application?.destroy().catch(report);
+ if (fallback && kind !== DEFAULT_ENGINE_CHOICE) {
+ // A stored choice that cannot start would fail on every visit.
+ await start(DEFAULT_ENGINE_CHOICE, { rememberChoice: true });
+ showStartFailure(error, DEFAULT_ENGINE_CHOICE);
+ } else {
+ report(error);
+ offerEngineRetry();
+ }
+ } finally {
+ starting = false;
+ }
+}
+
+void start(initialKind, {
+ fallback: !new URLSearchParams(location.search).has('engine'),
+});
export { application };
diff --git a/src/mapStartup.test.mjs b/src/mapStartup.test.mjs
index 42154ab33..513b88c82 100644
--- a/src/mapStartup.test.mjs
+++ b/src/mapStartup.test.mjs
@@ -81,7 +81,7 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) {
engineOfChoice: (choice) => (choice === 'cesium' ? 'cesium' : 'navara'),
createStandalonePlaceSearch: () => ({}),
createStandaloneCatalog: () => ({}),
- // Cesium's displays are in the main chunk: nothing to load.
+ // The startup path draws no layer: its displays need not load.
loadLayerRenderers: async () => {},
createGoogleTokenSource: () => createGoogleTokenSource({ fetchImpl }),
selectMapStartupRoute,
diff --git a/src/maps/defaultSources.js b/src/maps/defaultSources.js
index 18af37136..cfe82a415 100644
--- a/src/maps/defaultSources.js
+++ b/src/maps/defaultSources.js
@@ -8,8 +8,8 @@ import {
import { terrainSourceFor } from '../globe/cesium/terrain.js';
import { t } from '../i18n/index.js';
-const NAVARA_ION_IMAGERY_REASON =
- 'Bing imagery is not available on the experimental Navara engine';
+const navaraIonImageryReason = () =>
+ t('Bing imagery is not available on the experimental Navara engine');
/**
* Select sources and setup guidance without putting provider branches in the
@@ -53,7 +53,7 @@ export function createDefaultMapSources({
available: !ionImageryOnNavara && (!descriptor.requiresIon || hasIon),
// Read when shown, so it follows the locale.
unavailableReason: ionImageryOnNavara
- ? NAVARA_ION_IMAGERY_REASON
+ ? navaraIonImageryReason
: descriptor.requiresIon
? () =>
t('Needs {envVars} — add it in Provider Settings', {
diff --git a/src/maps/sourceFactories.test.mjs b/src/maps/sourceFactories.test.mjs
index a2b7a90fb..e347e6cc1 100644
--- a/src/maps/sourceFactories.test.mjs
+++ b/src/maps/sourceFactories.test.mjs
@@ -416,7 +416,7 @@ test('on the Navara engine photoreal keys on the browser key and ion imagery is
]) {
for (const id of ['bing-aerial', 'bing-labels']) {
assert.equal(byId(registry, id).available, false);
- assert.match(byId(registry, id).unavailableReason, /Navara/);
+ assert.match(byId(registry, id).unavailableReason(), /Navara/);
}
assert.equal(byId(registry, 'esri-imagery').available, true);
assert.equal(byId(registry, 'osm').available, true);
diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js
index 49ab7e9d0..37bb4b154 100644
--- a/src/overlays/worldOverlay.js
+++ b/src/overlays/worldOverlay.js
@@ -1148,12 +1148,6 @@ export function createWorldOverlay(engine) {
};
}
- /**
- * True when a candidate survives the explicit horizon/viewport policies.
- * The horizon test prefers `entry.cullPosition` when the source supplied one
- * (see normalizeOverlayEntry) so a sub-ellipsoid render anchor is not
- * false-hidden near the limb; everything else still uses the render position.
- */
/**
* Return true when a rectangle intersects any cached UI exclusion box.
* `count` lets pooled buffers expose a live prefix without truncating.
diff --git a/src/scenes/dataPacks/shapes.js b/src/scenes/dataPacks/shapes.js
index 843e98e02..075945542 100644
--- a/src/scenes/dataPacks/shapes.js
+++ b/src/scenes/dataPacks/shapes.js
@@ -6,9 +6,6 @@ import * as geo from '../../geo/index.js';
const GRANULARITY_DEGREES = 1;
function shape(positions, indices, st = null) {
- const center = geo.cartesian3.create();
- for (const p of positions) geo.cartesian3.add(center, p, center);
- geo.cartesian3.divideByScalar(center, positions.length || 1, center);
const values = new Float64Array(positions.length * 3);
positions.forEach((p, i) => values.set([p.x, p.y, p.z], i * 3));
return {
@@ -17,7 +14,6 @@ function shape(positions, indices, st = null) {
...(st ? { st: { values: st } } : {}),
},
indices: new Uint32Array(indices),
- boundingSphere: { center },
};
}
diff --git a/src/scenes/director.js b/src/scenes/director.js
index 81664493e..8a40829d0 100644
--- a/src/scenes/director.js
+++ b/src/scenes/director.js
@@ -1282,6 +1282,7 @@ export class SceneDirector {
return { started: false, reason: 'data-packs-unavailable' };
if (token.cancelled) return;
if (seekState) {
+ if (this._sceneGate && !(await this._waitForScene(token))) return;
this._setCameraView(
seekState.camera || resolveCameraPose(scene, shot.camera),
);
diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs
index f11347f93..6f2f8b9c4 100644
--- a/src/scenes/director.test.mjs
+++ b/src/scenes/director.test.mjs
@@ -2138,3 +2138,40 @@ for (const createHarness of GROUND_HARNESSES) {
}
});
}
+
+test('seek waits for a scene reattachment after loading data packs', async () => {
+ const { director, restore } = makeDirector();
+ let release;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ let waiting = false;
+ let activated = false;
+ director._applyDataPacks = async () => {
+ director._sceneGate = gate;
+ return true;
+ };
+ director._waitForScene = async () => {
+ waiting = true;
+ await gate;
+ return true;
+ };
+ director._activateInteractions = () => {
+ activated = true;
+ };
+ try {
+ const seeking = director.seekScene('scene-1', 0.25);
+ for (let i = 0; i < 15; i++) await Promise.resolve();
+ assert.equal(activated, false);
+ assert.equal(waiting, true);
+ director._sceneGate = null;
+ release();
+ await seeking;
+ assert.equal(activated, true);
+ } finally {
+ director._sceneGate = null;
+ release();
+ await director.destroy();
+ restore();
+ }
+});
diff --git a/src/scenes/interactions.js b/src/scenes/interactions.js
index 9ba7fb691..576dad323 100644
--- a/src/scenes/interactions.js
+++ b/src/scenes/interactions.js
@@ -121,8 +121,8 @@ export function createSceneInteractions({
handler = null;
engine = nextEngine;
if (registered) targets = registered;
+ container = engine?.host?.container ?? container;
if (!panel || !engine) return;
- container = engine.host?.container ?? container;
update({ container });
bindInput(panel.owner);
},
diff --git a/src/scenes/interactionsSceneSwitch.test.mjs b/src/scenes/interactionsSceneSwitch.test.mjs
new file mode 100644
index 000000000..a5e5624a7
--- /dev/null
+++ b/src/scenes/interactionsSceneSwitch.test.mjs
@@ -0,0 +1,61 @@
+// An engine switch before any shot with actions: the next panel must render
+// in the new engine's container, not the old (detached) one.
+import '../testSupport/registerDom.mjs';
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createApp, nextTick } from 'vue';
+import { createPinia } from 'pinia';
+import { useSceneInteractionsStore } from '../ui/stores/sceneInteractions.js';
+import SceneInteractions from '../ui/views/panels/SceneInteractions.vue';
+import { createSceneInteractions } from './interactions.js';
+import { createFakeEngine } from '../globe/testing/fakeEngine.js';
+
+const key = (featureId) => JSON.stringify(['qa', featureId]);
+const SHOT = {
+ interactions: [
+ {
+ id: 'card',
+ label: 'Read about it',
+ target: { packId: 'qa', featureId: 'point' },
+ action: { type: 'card', text: 'A note.' },
+ },
+ ],
+};
+
+test('Scene actions open in the new engine container after a switch with no panel', async () => {
+ document.body.replaceChildren();
+ const pinia = createPinia();
+ const app = createApp(SceneInteractions).use(pinia);
+ app.mount(document.body.appendChild(document.createElement('div')));
+ const makeEngine = () => {
+ const container = document.body.appendChild(document.createElement('div'));
+ const canvas = container.appendChild(document.createElement('canvas'));
+ return { container, engine: createFakeEngine({ container, canvas }) };
+ };
+ const first = makeEngine();
+ const interactions = createSceneInteractions({
+ engine: first.engine,
+ execute: async () => true,
+ store: useSceneInteractionsStore(pinia),
+ });
+ // The switch (director.suspendScene / attachScene), no panel shown.
+ interactions.setScene(null);
+ first.container.remove();
+ const second = makeEngine();
+ const targets = new Map([[key('point'), {}]]);
+ interactions.setScene(second.engine, new Map(targets));
+ interactions.activate(SHOT, new Map(targets));
+ for (let i = 0; i < 4; i += 1) await nextTick();
+ const inOld = Boolean(
+ first.container.querySelector('[data-director-interactions]'),
+ );
+ const inNew = Boolean(
+ second.container.querySelector('[data-director-interactions]'),
+ );
+ interactions.destroy();
+ app.unmount();
+ first.engine.destroy();
+ second.engine.destroy();
+ assert.equal(inOld, false, 'not in the old, detached container');
+ assert.equal(inNew, true, 'in the new container');
+});
diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js
index f462e9be0..7444920cc 100644
--- a/src/services/meshFloorSampler.js
+++ b/src/services/meshFloorSampler.js
@@ -69,16 +69,6 @@ export function createMeshFloorSampler({
// preference. Module-scope listener: the module only loads in the browser
// bundle, and the subscription is idempotent for the app's lifetime.
- /**
- * Equirectangular distance (km) — same approximation the flights clamp uses.
- */
- function _approxKm(lat1, lon1, lat2, lon2) {
- const dLat = (lat2 - lat1) * 111.32;
- const dLon =
- (lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
- return Math.hypot(dLat, dLon);
- }
-
/**
* Samples the rendered mesh height for the coarse cells containing `points`,
* one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per
diff --git a/src/sharelink.js b/src/sharelink.js
index 390b24ba1..5e1e2fba5 100644
--- a/src/sharelink.js
+++ b/src/sharelink.js
@@ -119,6 +119,9 @@ export class ShareLinkManager {
// The hash this page last read or wrote. A location change back to it is
// the app's own state, never an incoming share.
this._knownHash = null;
+ // The hash last written from the live camera, with that camera's exact
+ // pose: the address rounds it (1e-4°, whole metres and degrees).
+ this._writtenView = null;
this._unwatchNavigation = null;
this._onNavigate = null;
// Held while the page leaves this engine for another (holdNavigation).
@@ -179,6 +182,8 @@ export class ShareLinkManager {
parseInitialHash() {
const hash = window.location.hash.slice(1);
this._knownHash = hash;
+ const written = this._writtenView;
+ this._writtenView = null;
const code =
this._initialShareCode ??
parseSharePath(window.location.pathname)?.code ??
@@ -187,6 +192,9 @@ export class ShareLinkManager {
const state =
code === null ? this._parseShareHash(hash) : this._parseShareCode(code);
if (!state) return null;
+ // Another engine reopening the address this manager wrote (an engine
+ // switch) takes the live view it was written from.
+ if (code === null && written?.hash === hash) state.liveView = written.view;
this._beginIncomingRestore(state);
return state;
}
@@ -398,7 +406,7 @@ export class ShareLinkManager {
status: applyCamera ? 'superseded' : 'skipped',
});
if (applyCamera && instant && this._isNavigationCurrent(navigationToken)) {
- this.engine.camera.setView(view);
+ this.engine.camera.setView(state.liveView ?? view);
this.engine.render.request('engine-switch');
cameraPromise = Promise.resolve({ status: 'applied' });
} else if (applyCamera && this._isNavigationCurrent(navigationToken)) {
@@ -723,6 +731,17 @@ export class ShareLinkManager {
if (this._sharpenFollowsEngine) params.set('siAuto', '1');
url.hash = params.toString();
this._knownHash = url.hash.slice(1);
+ const pose = this.engine.camera.pose();
+ this._writtenView = {
+ hash: this._knownHash,
+ view: {
+ destination: geo.cartesian3.clone(pose.position),
+ orientation: {
+ direction: geo.cartesian3.clone(pose.direction),
+ up: geo.cartesian3.clone(pose.up),
+ },
+ },
+ };
history.replaceState(null, '', url.href);
}
diff --git a/src/sharelinkSwitchPose.test.mjs b/src/sharelinkSwitchPose.test.mjs
new file mode 100644
index 000000000..8d367aee8
--- /dev/null
+++ b/src/sharelinkSwitchPose.test.mjs
@@ -0,0 +1,77 @@
+// An engine switch reopens the address the outgoing camera wrote; the
+// incoming camera takes that camera's exact pose, as the address rounds it.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as geo from './geo/index.js';
+import { createFakeEngine } from './globe/testing/fakeEngine.js';
+import { ShareLinkManager } from './sharelink.js';
+
+function installTab(t) {
+ const prior = { window: globalThis.window, history: globalThis.history };
+ const location = new URL('http://localhost/?engine=cesium');
+ globalThis.window = Object.assign(new EventTarget(), { location });
+ globalThis.history = {
+ replaceState(_state, _title, next) {
+ location.href = new URL(next, location.href).href;
+ },
+ };
+ t.after(() => Object.assign(globalThis, prior));
+ return location;
+}
+
+const live = {
+ position: geo.cartesian3.fromDegrees(-123.1, 48.38298, 1587.2),
+ heading: 0.3,
+ pitch: geo.math.toRadians(-40.017),
+ roll: 0,
+};
+
+function poseError(engine) {
+ const pose = engine.camera.pose();
+ const angle = (key) =>
+ Math.abs(
+ Math.atan2(
+ Math.sin(pose[key] - live[key]),
+ Math.cos(pose[key] - live[key]),
+ ),
+ );
+ return {
+ metres: geo.cartesian3.distance(pose.position, live.position),
+ radians: Math.max(angle('heading'), angle('pitch'), angle('roll')),
+ };
+}
+
+test('an engine switch hands the live pose over, not the rounded address', async (t) => {
+ installTab(t);
+ const outgoing = createFakeEngine();
+ outgoing.fake.setPose(live);
+ const manager = new ShareLinkManager(outgoing);
+ t.after(() => manager.destroy());
+ manager.flush();
+ manager.attachEngine(null);
+ const incoming = createFakeEngine();
+ manager.attachEngine(incoming);
+ const state = manager.parseInitialHash();
+ assert.equal(state.alt, 1587, 'the address rounds the view');
+ await manager.applyState(state, { instant: true });
+ const { metres, radians } = poseError(incoming);
+ assert.ok(metres < 1e-6, `${metres} m`);
+ assert.ok(radians < 1e-9, `${radians} rad`);
+});
+
+test('a visited entry restores its own address, not the live pose', async (t) => {
+ const location = installTab(t);
+ const outgoing = createFakeEngine();
+ outgoing.fake.setPose(live);
+ const manager = new ShareLinkManager(outgoing);
+ t.after(() => manager.destroy());
+ manager.flush();
+ manager.attachEngine(null);
+ location.hash = 'v=2&lat=10.0000&lon=20.0000&alt=800&heading=0&pitch=-90';
+ const incoming = createFakeEngine();
+ manager.attachEngine(incoming);
+ await manager.applyState(manager.parseInitialHash(), { instant: true });
+ const place = incoming.camera.pose().cartographic;
+ assert.ok(Math.abs(geo.math.toDegrees(place.latitude) - 10) < 1e-9);
+ assert.ok(Math.abs(place.height - 800) < 1e-6);
+});
diff --git a/src/standalone/application.js b/src/standalone/application.js
index 3ae576273..1675c9fb4 100644
--- a/src/standalone/application.js
+++ b/src/standalone/application.js
@@ -116,8 +116,7 @@ export function createStandaloneApplication({
throw new AggregateError(errors, 'The globe could not be released');
};
const signal = AbortSignal.any([controller.signal, lifetime]);
- // The layers' engine-specific renderers of this engine (the layer
- // manifest's import()s, none on Cesium) load beside its globe.
+ // Shared layer displays and this engine's backends load beside its globe.
const layerRenderers = loadLayerRenderers(engineOfChoice(kind));
layerRenderers.catch(() => {});
try {
diff --git a/src/standalone/engineSwitch.js b/src/standalone/engineSwitch.js
index 9d07c89e8..7e04b2843 100644
--- a/src/standalone/engineSwitch.js
+++ b/src/standalone/engineSwitch.js
@@ -67,7 +67,10 @@ export function createEngineAddress({ location, history, flush = () => {} }) {
* was where the incoming address agrees (always after a pick) and follows a
* visited entry where it differs (handoffTargetEnabled).
*/
-export function handOffSession(session, { addressLayerIds = null } = {}) {
+export function handOffSession(
+ session,
+ { addressLayerIds = null, visit = false } = {},
+) {
const manager = session.manager;
const listed = addressLayerIds ? new Set(addressLayerIds) : null;
session.handoffLayers = new Map(
@@ -77,6 +80,7 @@ export function handOffSession(session, { addressLayerIds = null } = {}) {
entry: entry.module.attachRenderer ? entry : null,
enabled: manager.isEffectivelyEnabled(id),
durable: listed ? listed.has(id) : undefined,
+ visit,
},
]),
);
diff --git a/src/standalone/sceneSwitcher.js b/src/standalone/sceneSwitcher.js
index 6bab5b165..27dad0bbd 100644
--- a/src/standalone/sceneSwitcher.js
+++ b/src/standalone/sceneSwitcher.js
@@ -35,7 +35,7 @@ export function createSceneSwitcher({
return components.scene?.engine ? components : null;
};
- async function leave(from, _to, _change, transaction) {
+ async function leave(from, _to, change, transaction) {
const components = liveComponents();
if (components) {
cover ??= keepFrame?.(components.scene.engine);
@@ -44,6 +44,7 @@ export function createSceneSwitcher({
handOffSession(layerSession, {
addressLayerIds:
style.shareLinkManager?.currentLayerState?.()?.enabledLayerIds,
+ visit: change?.visit === true,
});
renderers.retain(from);
await getApplication().detachScene({ handoff: true }, transaction);
diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs
index b77fdcae5..2bc5ed764 100644
--- a/src/tooling/importDirections.test.mjs
+++ b/src/tooling/importDirections.test.mjs
@@ -298,6 +298,7 @@ test('module analysis can tell dynamic imports from static ones', () => {
imports: ['one', 'two', 'three', 'four'],
browser: [],
dynamicImports: ['three', 'four'],
+ staticImports: ['one', 'two'],
},
);
});
@@ -506,3 +507,32 @@ test('symlink aliases do not hide renderer ownership', (t) => {
);
assert.throws(() => checkImportDirections(root), /rendering|symlinks/);
});
+
+test('a dynamic import does not excuse a static import of the same engine', (t) => {
+ const { root, write } = fixture(t);
+ write(
+ 'src/app/scene.js',
+ "import '../globe/navara/index.js'; import('../globe/navara/index.js');",
+ );
+ write('src/globe/navara/index.js');
+ assert.throws(
+ () => checkImportDirections(root),
+ /only through a dynamic import/,
+ );
+});
+
+test('Vue script and script setup follow SDK ownership', (t) => {
+ const { root, write } = fixture(t);
+ write(
+ 'src/ui/View.vue',
+ ``,
+ );
+ assert.throws(
+ () => checkImportDirections(root),
+ (error) => {
+ assert.match(error.message, /cesium imports belong/);
+ assert.match(error.message, /three imports belong/);
+ return true;
+ },
+ );
+});
diff --git a/src/tooling/panelBuild.test.mjs b/src/tooling/panelBuild.test.mjs
index 0a7e1f364..ac06ed365 100644
--- a/src/tooling/panelBuild.test.mjs
+++ b/src/tooling/panelBuild.test.mjs
@@ -115,7 +115,7 @@ test("Cesium's workers get embedded files from memory and other requests from th
}
});
-test('the panel build leaves out the initial-chunk guard (it inlines Navara)', () => {
+test('the single-chunk panel build leaves out the initial-chunk guard', () => {
const kept = { name: 'kept' };
const config = panelBuildConfig({
build: {
diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js
index 78b718cd6..941711779 100644
--- a/src/ui/applicationShell.js
+++ b/src/ui/applicationShell.js
@@ -164,7 +164,6 @@ export class StyleManager extends ShellScene {
this.placeSearch = placeSearch;
this._navigation = new NavigationController({
- viewer,
engine,
tracking: {
flightsLayer,
@@ -196,7 +195,6 @@ export class StyleManager extends ShellScene {
this._cockpitDisplayModeHandler = null;
this._locationNavigation = new LocationNavigation({
- viewer,
engine,
placeSearch,
navigation: this._navigation,
diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js
index fb2d87709..ce17c3a98 100644
--- a/src/ui/cameraOrientationControls.js
+++ b/src/ui/cameraOrientationControls.js
@@ -2,10 +2,10 @@ import * as geo from '../geo/index.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
import { t } from '../i18n/index.js';
-// The camera is the globe engine's, and so is the follow: the followed
-// entity is engine.camera.following().native (on Cesium also an entity the
-// app put on viewer.trackedEntity). The viewer, when callers pass one, is
-// read only for its clock.
+// The camera is the globe engine's, and so is the follow. On Cesium the
+// follow is the viewer's tracked entity (an app entity, or the proxy of an
+// engine follow): its EntityView keeps whatever offset the camera takes in
+// the entity's frame. Elsewhere the follow owns its offset.
// Cesium's EasingFunction.CUBIC_IN_OUT (tween.js Cubic.InOut).
function cubicInOut(amount) {
@@ -13,17 +13,15 @@ function cubicInOut(amount) {
return 0.5 * ((amount -= 2) * amount * amount + 2);
}
-function followedEntity(engine) {
- const handle = engine?.camera.following?.();
- return handle?.native ?? null;
+function followedEntity(engine, viewer) {
+ return engine?.camera.following?.() ? (viewer?.trackedEntity ?? null) : null;
}
-// An engine's own follow (no tracked entity: Navara): its handle, which
-// knows its target and takes a new offset without ending the follow.
+// A follow handle that takes a new offset without ending the follow.
function engineFollow(engine, viewer) {
const handle = engine?.camera.following?.();
return handle &&
- !followedEntity(engine) &&
+ !followedEntity(engine, viewer) &&
typeof handle.target === 'function' &&
typeof handle.retarget === 'function'
? handle
@@ -84,12 +82,12 @@ export function pickViewTarget(engine) {
/**
* Describe the camera as an orbit around the current viewport center.
* @param {import('../globe/contract.js').GlobeEngine | null} engine
- * @param {object | null} [viewer] The Cesium viewer whose tracked entity is
- * the orbit target.
+ * @param {object | null} [viewer] The Cesium viewer: its tracked entity and
+ * the clock that samples it.
*/
export function readCameraTargetFrame(engine, viewer = null) {
if (!engine) return null;
- const entity = followedEntity(engine);
+ const entity = followedEntity(engine, viewer);
const follow = engineFollow(engine, viewer);
const center = entity
? trackedTarget(entity, viewer)
@@ -133,8 +131,7 @@ function setCameraTargetFrame(engine, frame, viewer = null) {
if (!engine || !frame?.target || !Number.isFinite(frame.range)) return false;
const { camera } = engine;
// Inside an engine's own follow: the new frame is the follow's offset (a
- // camera command would end the follow; Cesium's tracked entity keeps its
- // follow through lookAt below, so both engines keep following).
+ // camera command would end the follow).
const follow = engineFollow(engine, viewer);
if (follow) {
const heading = normalizedHeading(frame.heading);
@@ -150,19 +147,26 @@ function setCameraTargetFrame(engine, frame, viewer = null) {
return true;
}
try {
- const trackedTransform = followedEntity(engine) ? camera.transform() : null;
- camera.lookAt(frame.target, {
+ const offset = {
heading: normalizedHeading(frame.heading),
pitch: frame.pitch,
range: frame.range,
- });
- if (trackedTransform) {
- // Preserve EntityView's reference frame (including satellite frames).
- // Its next update carries this new local offset along with the target.
+ };
+ if (followedEntity(engine, viewer)) {
+ // lookAt is a camera command and would end an engine follow; the same
+ // frame through lookAtTransform does not. Then back to EntityView's
+ // reference frame (including satellite frames): its next update
+ // carries this new local offset along with the target.
+ const trackedTransform = camera.transform();
+ camera.lookAtTransform(
+ geo.transforms.eastNorthUpToFixedFrame(frame.target),
+ offset,
+ );
camera.lookAtTransform(trackedTransform);
engine.render.request('camera-orientation');
return true;
}
+ camera.lookAt(frame.target, offset);
const { position: destination, direction, up } = camera.pose();
camera.release();
if (destination && direction && up) {
@@ -238,14 +242,14 @@ export function createCameraOrientationAnimator(
if (!engine) return false;
if (duration <= 0)
return setCameraTargetFrame(engine, { ...frame, ...destination }, viewer);
- const entity = followedEntity(engine);
+ const entity = followedEntity(engine, viewer);
pending = destination;
const start = now();
const headingDelta = geo.math.negativePiToPi(
destination.heading - frame.heading,
);
remove = engine.frames.preUpdate.addEventListener(() => {
- if (engine.isDestroyed() || followedEntity(engine) !== entity) {
+ if (engine.isDestroyed() || followedEntity(engine, viewer) !== entity) {
cancel();
return;
}
@@ -291,7 +295,7 @@ export function createCameraOrientationAnimator(
* runNavigation: Function,
* showToast?: Function,
* animationMs?: number,
- * }} options The viewer is only read for its clock and tracked entity.
+ * }} options The viewer (Cesium) is read for its tracked entity and clock.
*/
export function bindCameraOrientationControls({
viewer = null,
diff --git a/src/ui/cameraOrientationControls.test.mjs b/src/ui/cameraOrientationControls.test.mjs
index 3c1735a57..cef4a9004 100644
--- a/src/ui/cameraOrientationControls.test.mjs
+++ b/src/ui/cameraOrientationControls.test.mjs
@@ -64,6 +64,7 @@ function createViewer({ cameraPosition, target, pickPosition = null } = {}) {
lookAtTransform(transform) {
calls.push({ type: 'lookAtTransform', transform });
},
+ cancelFlight() {},
setView(view) {
calls.push({ type: 'setView', view });
},
diff --git a/src/ui/cameraOrientationNativeFollow.test.mjs b/src/ui/cameraOrientationNativeFollow.test.mjs
new file mode 100644
index 000000000..337a71de8
--- /dev/null
+++ b/src/ui/cameraOrientationNativeFollow.test.mjs
@@ -0,0 +1,98 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import * as Cesium from 'cesium';
+import {
+ readCameraTargetFrame,
+ resetCameraNorth,
+ toggleCameraTilt,
+} from './cameraOrientationControls.js';
+import { createFakeEngine } from '../globe/testing/fakeEngine.js';
+import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js';
+
+// The aircraft and satellite displays pass native: handle to engine.camera.follow
+// (src/layers/aircraft/render/display.js, src/layers/satellites/render/display.js).
+const target = Cesium.Cartesian3.fromDegrees(-73.78, 40.64, 3000);
+const aircraft = {
+ gevTrackedId: 'flight:abc',
+ gevDisplayPosition: () => target,
+};
+const followAircraft = (engine) =>
+ engine.camera.follow({
+ native: aircraft,
+ position: () => target,
+ viewFrom: { x: 1400, y: -1400, z: 1000 },
+ });
+
+// A Cesium viewer with a real camera and the tracked-entity surface the
+// Cesium follow drives (no EntityView: the follow's own frame holds).
+function cesiumViewer() {
+ const scene = {
+ canvas: { clientWidth: 1200, clientHeight: 800, width: 1200, height: 800 },
+ drawingBufferWidth: 1200,
+ drawingBufferHeight: 800,
+ mapProjection: new Cesium.GeographicProjection(Cesium.Ellipsoid.WGS84),
+ globe: { ellipsoid: Cesium.Ellipsoid.WGS84, pick: () => target },
+ mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
+ mode: Cesium.SceneMode.SCENE3D,
+ pixelRatio: 1,
+ pickPositionSupported: false,
+ requestRender() {},
+ preRender: new Cesium.Event(),
+ preUpdate: new Cesium.Event(),
+ postRender: new Cesium.Event(),
+ screenSpaceCameraController: { inertiaZoom: 0.8, minimumZoomDistance: 1 },
+ };
+ const camera = new Cesium.Camera(scene);
+ scene.camera = camera;
+ let tracked;
+ const trackedEntityChanged = new Cesium.Event();
+ const viewer = {
+ scene,
+ camera,
+ canvas: scene.canvas,
+ clock: { currentTime: Cesium.JulianDate.now(), onTick: new Cesium.Event() },
+ entities: { add: (options) => new Cesium.Entity(options), remove() {} },
+ isDestroyed: () => false,
+ trackedEntityChanged,
+ get trackedEntity() {
+ return tracked;
+ },
+ set trackedEntity(entity) {
+ if (entity === tracked) return;
+ tracked = entity;
+ trackedEntityChanged.raiseEvent(entity);
+ },
+ };
+ viewer.engine = createCesiumEngine(viewer);
+ return viewer;
+}
+
+for (const [name, act, check] of [
+ ['north-up', resetCameraNorth, (frame) => frame.heading],
+ ['tilt', toggleCameraTilt, (frame) => frame.pitch + Math.PI / 2],
+]) {
+ test(`${name} keeps an engine follow that carries a native handle (Navara aircraft)`, () => {
+ const engine = createFakeEngine({ kind: 'navara' });
+ const handle = followAircraft(engine);
+ engine.fake.frame();
+ assert.ok(act(engine), `${name} applied`);
+ engine.fake.frame();
+ assert.equal(engine.camera.following(), handle, `${name} keeps the follow`);
+ handle.stop();
+ engine.destroy();
+ });
+
+ test(`${name} keeps an engine follow on Cesium (aircraft)`, () => {
+ const viewer = cesiumViewer();
+ const { engine } = viewer;
+ const handle = followAircraft(engine);
+ viewer.scene.preUpdate.raiseEvent();
+ assert.ok(act(engine, viewer), `${name} applied`);
+ viewer.scene.preUpdate.raiseEvent();
+ assert.equal(engine.camera.following(), handle, `${name} keeps the follow`);
+ const frame = readCameraTargetFrame(engine, viewer);
+ assert.ok(Math.abs(check(frame)) < 0.02, `${name} reached the camera`);
+ handle.stop();
+ engine.destroy();
+ });
+}
diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js
index e1f03044a..2920cd12c 100644
--- a/src/ui/cockpitController.js
+++ b/src/ui/cockpitController.js
@@ -206,7 +206,7 @@ export class CockpitViewController {
this.scratchAnchorCartographic = geo.cartographic.create();
this.scratchCameraCartographic = geo.cartographic.create();
this.scratchTargetCartographic = geo.cartographic.create();
- this._listenerRemovers = [];
+ this._listenerRemovers = [onLocaleChange(() => this.relocalize())];
// Camera mutations belong before scene update/culling. Changing the camera
// from preRender makes 3D Tiles discover a new view after traversal and can
diff --git a/src/ui/cockpitController.test.mjs b/src/ui/cockpitController.test.mjs
index b73c9f679..a7279e972 100644
--- a/src/ui/cockpitController.test.mjs
+++ b/src/ui/cockpitController.test.mjs
@@ -306,3 +306,23 @@ test('the cockpit eye stays above the engine surface when the aircraft reads bel
env.restore();
}
});
+
+test('locale changes relocalize the cockpit until it is disposed', async () => {
+ const { getLocale, setLocale } = await import('../i18n/index.js');
+ const locale = getLocale();
+ const env = environment();
+ const owner = new CockpitViewController(env.engine, { services: {} });
+ let updates = 0;
+ owner.relocalize = () => updates++;
+ try {
+ setLocale(locale === 'de' ? 'en' : 'de');
+ assert.equal(updates, 1);
+ owner.dispose();
+ setLocale(locale);
+ assert.equal(updates, 1);
+ } finally {
+ owner.dispose();
+ setLocale(locale);
+ env.restore();
+ }
+});
diff --git a/src/ui/commandDock.test.mjs b/src/ui/commandDock.test.mjs
index c70007a17..9cf5e4a58 100644
--- a/src/ui/commandDock.test.mjs
+++ b/src/ui/commandDock.test.mjs
@@ -486,3 +486,36 @@ test('the search toggle opens the field, and a lookup marks it', async (t) => {
assert.equal(field.classList.contains('searching'), false);
assert.equal(field.classList.contains('expanded'), false);
});
+
+test('the engine picker stays focusable and announces an engine switch', async (t) => {
+ const { $, pinia } = fixture(t);
+ const display = useDisplayStore(pinia);
+ const selected = [];
+ const select = (kind) => selected.push(kind);
+ display.presentGlobeEngine({ kind: 'cesium', busy: false, select });
+ await nextTick();
+ const picker = $('#globe-engine');
+ picker.focus();
+ display.presentGlobeEngine({ kind: 'cesium', busy: true, select });
+ await nextTick();
+ assert.equal(picker.disabled, false);
+ assert.equal(document.activeElement, picker);
+ assert.equal(picker.getAttribute('aria-busy'), 'true');
+ assert.equal(picker.getAttribute('aria-disabled'), 'true');
+ assert.equal(
+ $('#globe-engine-status').textContent,
+ 'Initializing systems...',
+ );
+ picker.value = 'navara';
+ picker.dispatchEvent(new Event('change', { bubbles: true }));
+ assert.deepEqual(selected, []);
+ await nextTick();
+ assert.equal(picker.value, 'cesium', 'a refused pick is put back');
+ display.presentGlobeEngine({ kind: 'navara', busy: false, select });
+ await nextTick();
+ assert.equal(document.activeElement, picker);
+ assert.equal(picker.value, 'navara');
+ assert.ok(picker.classList.contains('gev-field'));
+ assert.equal($('#globe-engine-status').getAttribute('role'), 'status');
+ assert.equal($('#globe-engine-status').textContent, 'Navara');
+});
diff --git a/src/ui/geoFileImport.js b/src/ui/geoFileImport.js
index 1e5acb468..20cf6f83e 100644
--- a/src/ui/geoFileImport.js
+++ b/src/ui/geoFileImport.js
@@ -403,6 +403,7 @@ export function createGeoFileImport({
return;
}
await draw(item, attached, loaded);
+ if (!imports.includes(item)) undraw(item);
} catch {
if (destroyed || scene !== attached) return;
setHint(`${item.name}: ${t('That file could not be read.')}`);
diff --git a/src/ui/geoFileImportRedraw.test.mjs b/src/ui/geoFileImportRedraw.test.mjs
new file mode 100644
index 000000000..4a6bf1c04
--- /dev/null
+++ b/src/ui/geoFileImportRedraw.test.mjs
@@ -0,0 +1,75 @@
+// A file removed while its drawing on the next engine is still pending
+// (icon decode) must not leave that drawing on the globe.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createGeoFileImport } from './geoFileImport.js';
+import { emptyRecords } from '../data/geoImportRecords.js';
+
+const el = () => ({
+ classList: { toggle() {} },
+ addEventListener() {},
+ removeEventListener() {},
+});
+const engine = () => ({
+ host: { container: el(), canvas: el() },
+ camera: { moveEnd: { addEventListener: () => () => {} } },
+ surface: { tilesLoaded: () => true },
+ render: { request() {} },
+});
+
+test('removing a file during its redraw destroys the late drawing', async () => {
+ const document = {
+ ...el(),
+ body: el(),
+ getElementById: (id) => (id === 'geo-import-button' ? el() : null),
+ };
+ let actions = null;
+ const live = new Set();
+ let gate = null;
+ const drawFile = async () => {
+ if (gate) await gate.promise; // the icon decode of the redraw
+ const drawing = {
+ report: { drawn: 1, undrawn: 0 },
+ setShow() {},
+ refreshGround: async () => {},
+ footprint: () => [],
+ destroy: () => live.delete(drawing),
+ };
+ live.add(drawing);
+ return drawing;
+ };
+ const tool = createGeoFileImport({
+ document,
+ drawFile,
+ load: async (f) => ({
+ records: emptyRecords(),
+ name: f.name,
+ format: 'kmz',
+ entityCount: 1,
+ removed: 0,
+ revoke() {},
+ }),
+ flyToSource() {},
+ connect: (next) => ((actions = next), () => {}),
+ });
+ tool.attachScene({ engine: engine() });
+ await tool.importFiles([{ name: 'a.kmz', size: 1 }]);
+ const [{ id }] = tool.list();
+ assert.equal(live.size, 1);
+
+ // Engine switch: the drawing leaves; the next engine draws the file again.
+ tool.detachScene();
+ assert.equal(live.size, 0);
+ let release;
+ gate = { promise: new Promise((r) => (release = r)) };
+ tool.attachScene({ engine: engine() });
+ for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
+ // The user removes the row while the icon still decodes.
+ assert.equal(actions.removeGeoImport(id), true);
+ assert.equal(tool.list().length, 0);
+ release();
+ for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
+ assert.equal(live.size, 0, 'the removed file left a drawing on the globe');
+ tool.destroy();
+ assert.equal(live.size, 0, 'and destroy() cannot release it either');
+});
diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js
index 80e62147a..579b39df1 100644
--- a/src/ui/locationNavigation.js
+++ b/src/ui/locationNavigation.js
@@ -7,12 +7,10 @@ import { LocationControls } from './location.js';
/**
* Own destination selection, lookup, orbit and world-jump lifetime. The camera
* is the globe `engine`'s, and a world jump stops its follow
- * (`engine.camera.following()`); `viewer` is optional and only clears a
- * Cesium tracked entity the engine does not report.
+ * (`engine.camera.following()`).
*/
export class LocationNavigation {
constructor({
- viewer = null,
engine = null,
placeSearch,
services,
@@ -23,7 +21,7 @@ export class LocationNavigation {
}) {
Object.assign(
this,
- { viewer, engine, placeSearch, services, navigation, readCockpit },
+ { engine, placeSearch, services, navigation, readCockpit },
elements,
operations,
);
@@ -405,7 +403,6 @@ export class LocationNavigation {
controls?.closeSearch();
}
- /** Revoke callbacks and settle owned camera work before the viewer is released. */
/**
* Leave the globe (an engine switch): its reset flight, world-jump
* transition and orbit end; the search, its text and the readout stay.
@@ -420,14 +417,12 @@ export class LocationNavigation {
this._stopOrbit();
}
this.orbitController?.attachEngine(null);
- this.viewer = null;
this.engine = null;
}
/** Navigate on another globe (an engine switch). */
- attachScene({ viewer = null, engine = null } = {}) {
+ attachScene({ engine = null } = {}) {
if (this._disposed) return;
- this.viewer = viewer;
this.engine = engine;
this.orbitController?.attachEngine(engine);
}
diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js
index 4acf9ee33..503da36a0 100644
--- a/src/ui/navigationController.js
+++ b/src/ui/navigationController.js
@@ -9,12 +9,10 @@ import {
/**
* Own camera authority generations, pending search UI and tracking handoff.
- * The camera is the globe `engine`'s. `viewer` is optional and only clears
- * Cesium's tracked entity.
+ * The camera and tracked subject belong to the globe `engine`.
*/
export class NavigationController {
constructor({
- viewer = null,
engine = null,
tracking,
settleSearch = () => {},
@@ -29,7 +27,6 @@ export class NavigationController {
cancelOrientation = () => {},
}) {
Object.assign(this, {
- viewer,
engine,
tracking,
settleSearch,
@@ -234,7 +231,7 @@ export class NavigationController {
* flight cannot land on the next one. `retainScene` keeps the selection a
* handoff carries across.
*/
- setScene({ viewer = null, engine = null } = {}, { retainScene = true } = {}) {
+ setScene({ engine = null } = {}, { retainScene = true } = {}) {
if (this._disposed) return;
if (this.engine && engine !== this.engine) {
cancelCameraArrival(this.engine);
@@ -243,7 +240,6 @@ export class NavigationController {
clearSearchedLocation: false,
});
}
- this.viewer = viewer;
this.engine = engine;
}
/** Subscribe to ownership changes without claiming the camera or exposing mutable state. */
diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js
index 4f7436e51..7ec7a485c 100644
--- a/src/ui/shareRestoration.js
+++ b/src/ui/shareRestoration.js
@@ -17,6 +17,7 @@ export function layersNotOnThisEngine(results) {
import { stampInitialShareGesture } from '../navigationPolicy.js';
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
import { t, msg, formatDate } from '../i18n/index.js';
+import { localizeLayerName } from './layerNames.js';
import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js';
import { UiLifetime } from './uiLifetime.js';
@@ -411,9 +412,11 @@ export class ShareRestoration {
if (this._disposed || !Array.isArray(results)) return;
const names = layersNotOnThisEngine(results);
if (!names.length) return;
- this.showStatus('NOT ON THIS ENGINE', {
+ this.showStatus(t('NOT ON THIS ENGINE'), {
state: 'retry',
- detail: `Kept in the link: ${names.join(', ')}`,
+ detail: t('Kept in the link: {layers}', {
+ layers: names.map(localizeLayerName).join(', '),
+ }),
// Long enough to read a list of layer names; the engine's own
// persistent notice comes back afterwards (ShellFeedback).
dwellMs: NOT_ON_THIS_ENGINE_DWELL_MS,
diff --git a/src/ui/shellScene.js b/src/ui/shellScene.js
index 57912eb5b..b5b285332 100644
--- a/src/ui/shellScene.js
+++ b/src/ui/shellScene.js
@@ -333,8 +333,8 @@ export class ShellScene extends ShellFacade {
militaryAwarenessLayer,
rocketLaunchesLayer,
};
- this._navigation.setScene({ viewer, engine });
- this._locationNavigation.attachScene({ viewer, engine });
+ this._navigation.setScene({ engine });
+ this._locationNavigation.attachScene({ engine });
this.hud.attachEngine(engine, { viewer });
this.shareLinkManager.attachEngine(engine);
this._displayBindings.attachEngine(engine);
diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css
index 91115e206..270a4048d 100644
--- a/src/ui/styles/controls.css
+++ b/src/ui/styles/controls.css
@@ -795,6 +795,22 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
outline: 2px solid currentColor;
}
+/* While a switch runs the picker keeps focus (aria-disabled, not disabled). */
+#globe-engine[aria-busy='true'] {
+ opacity: var(--gev-field-disabled-opacity, 0.45);
+ cursor: progress;
+}
+
+/* Announces the switch to screen readers only. */
+#globe-engine-status {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
.map-source-section {
display: grid;
gap: 6px;
diff --git a/src/ui/views/CommandDockView.vue b/src/ui/views/CommandDockView.vue
index e06832b22..eb4d6ac47 100644
--- a/src/ui/views/CommandDockView.vue
+++ b/src/ui/views/CommandDockView.vue
@@ -3,6 +3,7 @@ import { storeToRefs } from 'pinia';
import { msg, t } from '../../i18n/index.js';
import { vPanelState } from '../rail/panelState.js';
import UiButton from '../kit/UiButton.vue';
+import UiSelect from '../kit/UiSelect.js';
import { vClassToggle } from '../rail/classToggle.js';
import { useDisplayStore } from '../stores/display.js';
import { computed } from 'vue';
@@ -18,6 +19,8 @@ import VoiceControl from './panels/VoiceControl.vue';
// button calls the Display controls' owner, a chip the map source controls.
const display = useDisplayStore();
const { style, mapSource, mapSourceControls, globeEngine } = storeToRefs(display);
+// Product names, as the picker's options show them.
+const ENGINE_NAMES = { cesium: 'Cesium', navara: 'Navara', 'navara-hd': 'Navara HD' };
// LocationControls publishes the location tray; pills call the controls,
// and the search field keeps their listeners.
const { view: loc, controls: locationControls } = storeToRefs(useLocationStore());
@@ -115,13 +118,14 @@ function selectMapSource(chip) {
+ {{ globeEngine.busy ? t('Initializing systems...') : ENGINE_NAMES[globeEngine.kind] }}
diff --git a/src/ui/wildlifeSightingCard.js b/src/ui/wildlifeSightingCard.js
index d0b642e6e..5c9544bc7 100644
--- a/src/ui/wildlifeSightingCard.js
+++ b/src/ui/wildlifeSightingCard.js
@@ -192,7 +192,7 @@ export function createWildlifeSightingCard({
if (fresh) panForId = null;
nextTick(() => {
if (destroyed || !selected.value) return;
- position({ allowPan: fresh || metadata?.explicitSelection === true });
+ position({ allowPan: metadata?.explicitSelection === true });
if (moving) startFrameUpdates();
// Focus the container so keyboard navigation starts inside the card.
if (fresh)
diff --git a/src/ui/wildlifeSightingCard.test.mjs b/src/ui/wildlifeSightingCard.test.mjs
index 14c1f5049..3ce55af11 100644
--- a/src/ui/wildlifeSightingCard.test.mjs
+++ b/src/ui/wildlifeSightingCard.test.mjs
@@ -808,3 +808,31 @@ test('newly mounted chrome repositions the open card without camera events or id
'closing disconnects structural observation',
);
});
+
+test('remounting a selected sighting does not pan the new engine', async (context) => {
+ const f = fixture(context);
+ await f.click();
+ f.card.destroy();
+ f.obstacle('.hud-top-left', rect(0, 0, 260, 160));
+ const ui = createUiRuntime();
+ let pans = 0;
+ const card = createWildlifeSightingCard({
+ layer: f.layer,
+ ui,
+ engine: f.engine,
+ documentRef: document,
+ windowRef: f.windowRef,
+ now: () => NOW,
+ project: () => ({ x: 100, y: 100 }),
+ pan: () => {
+ pans++;
+ return true;
+ },
+ });
+ context.after(() => {
+ card.destroy();
+ ui.destroy();
+ });
+ await settle();
+ assert.equal(pans, 0);
+});
diff --git a/src/voice/areaActions.js b/src/voice/areaActions.js
index 1aef6a8d8..594a3e8f0 100644
--- a/src/voice/areaActions.js
+++ b/src/voice/areaActions.js
@@ -162,15 +162,12 @@ export function createVoiceAreas({
findRegion = findNaturalRegion,
findAdminCandidates = findBundledAdminCandidates,
aroundOutlineMs = AROUND_OUTLINE_MS,
-} = {}) {
- // An area an outline on the map still refers to is never evicted: its
- // counting geometry must outlive the display copy drawn from it.
- const store = createAreaStore({
+ store = createAreaStore({
isPinned: (areaId) =>
typeof annotations?.list === 'function' &&
annotations.list().some((anno) => anno?.areaId === areaId),
- });
-
+ }),
+} = {}) {
/** The ground at the centre of the view, else the camera's position. */
function viewCenter() {
const picked = annotationResolver?.pickWorldFromScreen?.(engine, 0.5, 0.5);
diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js
index 56b068f58..eef8158ac 100644
--- a/src/voice/gevActions.js
+++ b/src/voice/gevActions.js
@@ -374,7 +374,7 @@ export function createGevActionRunner({
cameraHandoffs += 1;
}) ?? null;
let analystEngine;
- // Area handles live for this runner (the page): resolve_area, the
+ // Area handles live for the page session: resolve_area, the
// analyst's region/area/drawn scopes, find_imagery and osm_query all read
// the same store.
const voiceAreas = createVoiceAreas({
@@ -3314,12 +3314,14 @@ export async function getBasemapLabelContext(
const latitude = Number(geo.math.toDegrees(viewTarget.latitude).toFixed(6));
const longitude = Number(geo.math.toDegrees(viewTarget.longitude).toFixed(6));
+ const pose = engine.camera.pose();
const view = placeLookupView({
latitude,
longitude,
rangeM: viewTargetRangeM(engine, viewTarget, cameraHeightM),
- fovRad: engine.camera.pose().fovy,
- aspectRatio: engine.camera.pose().aspectRatio,
+ fovRad:
+ 2 * Math.atan(Math.tan(pose.fovy / 2) * Math.max(1, pose.aspectRatio)),
+ aspectRatio: pose.aspectRatio,
tier: basemapLookupTier(cameraHeightM),
});
if (
diff --git a/src/voice/gevRealtime.js b/src/voice/gevRealtime.js
index 64df161c6..59f1cdd3c 100644
--- a/src/voice/gevRealtime.js
+++ b/src/voice/gevRealtime.js
@@ -6,6 +6,7 @@ import { cameraPoseKey, createPointerTracker } from './pointerContext.js';
import { createPointerPicker } from './pointerPick.js';
import { createReferentRegistry } from './referents.js';
import { createSceneRunner } from './sceneRunner.js';
+import { createAreaStore } from '../data/areaStore.js';
export * from './realtimeController.js';
const ACTION_NAMES = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));
@@ -45,6 +46,14 @@ export function withToolCatalog(runner, loadCatalog) {
*/
export function createSceneVoice(options) {
const scene = { engine: null };
+ let areaAnnotations = null;
+ const areaStore =
+ options.areaOptions?.store ??
+ createAreaStore({
+ isPinned: (areaId) =>
+ areaAnnotations?.list?.().some((anno) => anno?.areaId === areaId) ??
+ false,
+ });
const cameraKey = () => cameraPoseKey(scene.engine);
let pick = null;
// Point-and-ask: the tracker and referents are shared by the runner (which
@@ -74,6 +83,7 @@ export function createSceneVoice(options) {
deixis,
analystMemory,
layerEnabledAt,
+ areaOptions: { ...options.areaOptions, store: areaStore },
readVoicePanelOperation: () =>
commands?.captureVoicePanelOperation?.(),
}),
@@ -141,6 +151,7 @@ export function createSceneVoice(options) {
if (lifetime?.aborted) return;
detachScene();
const engine = attached.engine ?? null;
+ areaAnnotations = attached.annotations ?? null;
scene.engine = engine;
shell = attached.styleManager ?? null;
pick = createPointerPicker({
diff --git a/src/voice/hudPlaceLabels.test.mjs b/src/voice/hudPlaceLabels.test.mjs
index 7e4b94ec5..6f94085da 100644
--- a/src/voice/hudPlaceLabels.test.mjs
+++ b/src/voice/hudPlaceLabels.test.mjs
@@ -368,3 +368,17 @@ test('cached-only lookups read every cached viewport sample and never request',
assert.ok(b.placeLabels.includes(cachedLabel));
assert.deepEqual(service.calls, before);
});
+
+test('basemap lookup measures the horizontal camera span', async () => {
+ const { viewer } = createViewer({
+ latitude: 30,
+ longitude: -97,
+ heightM: 1000,
+ });
+ const result = await getBasemapLabelContext(viewer, createService(), {
+ cachedOnly: true,
+ });
+ assert.ok(
+ Math.abs(result.view.spanM - 2 * 1000 * Math.tan(Math.PI / 6) * 1.5) < 1,
+ );
+});
diff --git a/src/voice/sceneVoice.test.mjs b/src/voice/sceneVoice.test.mjs
index e2015bc1d..57d1493bd 100644
--- a/src/voice/sceneVoice.test.mjs
+++ b/src/voice/sceneVoice.test.mjs
@@ -115,3 +115,33 @@ test('annotation outline events reach the session from the attached engine only'
f.restore();
}
});
+
+test('area handles outlive an engine attachment', () => {
+ const f = fixture();
+ try {
+ f.voice.attachScene({ engine: fakeEngine('cesium') });
+ const store = f.built[0].areaOptions?.store;
+ assert.ok(store);
+ const area = store.put({
+ name: 'Area',
+ source: 'drawn',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [0, 0],
+ [1, 0],
+ [1, 1],
+ [0, 0],
+ ],
+ ],
+ },
+ });
+ f.voice.detachScene();
+ f.voice.attachScene({ engine: fakeEngine('navara') });
+ assert.equal(f.built[1].areaOptions.store.get(area.areaId), area);
+ } finally {
+ f.lifetime.abort();
+ f.restore();
+ }
+});