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', + [], + '
Date2026-01-10
', + ), + 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(); + } +});