From 60fb8fee07a8e668244b51118633bba467009245 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 01:57:11 +0400 Subject: [PATCH] Fix the follow-up review findings in import, layers, startup and engines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KML/KMZ import: a per-file canvas budget and cache for ground overlays, limits on rings and vertices of raised polygons, overlays across 360° and near the poles, degenerate holes, polar and single-latitude fills, absolute overlays at their altitude, overlay order by drawOrder as on Cesium, and pins on tiles keep their measured height. - Points on the surface: OSM Places and Street Level cones share the surface sampler and sit on the terrain before they are measured, within a sampling budget per pass; Cockpit's contact-dot bank keeps the dot atlas, and a failed fleet model loads again after a pause. - Startup: the loading screen lifts after the share-link view is restored, the retry screen names the failed engine and takes focus, and the fallback toast stays for 6 s. - Engines: an engine switch lands a running camera flight at its destination (camera.completeFlight on both engines), the sighting card leaves focus on the engine picker after a switch, and a Cesium model error stays with the instance that failed. - The MCP panel builds again: its wind-mesh alias resolves to the stub. - QA: the wildlife switch waits for the camera to rest, GBFS --w2 says it needs the dev server, and test fixtures are shared instead of copied. --- build/panel.js | 2 +- build/panelAlias.test.mjs | 45 ++ build/panelResources.test.mjs | 5 +- docs/CURRENT-STATE.md | 47 +- docs/GLOBE-PRIMITIVES.md | 4 +- scripts/module-analysis.mjs | 11 +- scripts/package-boundaries.json | 2 + scripts/qa-gbfs-names.mjs | 8 + scripts/qa-gbfs-names.test.mjs | 50 ++ scripts/qa-strict-session.test.mjs | 69 ++- scripts/qa-wildlife-switch.mjs | 14 +- src/app/engineChoice.js | 10 +- src/app/layerSession.js | 2 +- src/app/mainStartup.test.mjs | 136 ++++- src/data/bhoteKoshiEvent.js | 9 +- src/data/bhoteKoshiEvent.test.mjs | 40 +- src/data/geoImportDisplay.js | 138 +++-- src/data/geoImportDisplay.test.mjs | 559 +++++++++++++++++- src/data/geoImportIconBudget.test.mjs | 97 --- src/data/geoImportKml.js | 9 +- src/data/geoImportKml.test.mjs | 38 ++ src/data/geoImportRaisedPolygon.test.mjs | 112 ---- src/data/geoImportRecords.js | 16 +- src/globe/cesium/camera.js | 13 +- src/globe/cesium/camera.test.mjs | 27 + src/globe/cesium/imagery.test.mjs | 36 ++ src/globe/cesium/imageryStageCredit.test.mjs | 162 ----- .../cesium/imageryStageRequests.test.mjs | 162 ----- src/globe/cesium/movers.js | 15 +- src/globe/cesium/movers.test.mjs | 36 ++ src/globe/contract.js | 6 + src/globe/navara/cameraChanged.test.mjs | 141 ----- src/globe/navara/cameraModel.js | 49 +- src/globe/navara/cameraModel.test.mjs | 137 +++++ src/globe/navara/createNavaraEngine.test.mjs | 30 + src/globe/navara/flightInput.test.mjs | 186 ------ src/globe/navara/input.test.mjs | 58 ++ src/globe/navara/inputPinch.test.mjs | 104 ---- src/globe/navara/overlayPass.test.mjs | 38 ++ src/globe/navara/overlaySonar.test.mjs | 110 ---- src/globe/testing/contractSuite.mjs | 34 ++ src/globe/testing/fakeEngine.js | 8 + src/i18n/locales/ar.json | 4 +- src/i18n/locales/de.json | 4 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/fr.json | 4 +- src/i18n/locales/it.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/pt.json | 4 +- src/i18n/locales/ru.json | 4 +- src/i18n/locales/uk.json | 4 +- src/i18n/locales/zh-CN.json | 4 +- src/i18n/locales/zh-Hant.json | 4 +- .../render/cockpitContactDot.test.mjs | 48 ++ src/layers/aircraft/render/display.js | 27 +- .../aircraft/trackedModelFailure.test.mjs | 109 +++- src/layers/cctv/viewFovy.test.mjs | 1 - .../flights/hiddenContactRotation.test.mjs | 105 +--- src/layers/flights/rendering.js | 4 + .../render/inViewContext.test.mjs | 5 +- src/layers/localAdsb/models.js | 4 + src/layers/localAdsb/models.test.mjs | 42 ++ src/layers/military/rendering.js | 4 + src/layers/osmPlaces/render/display.js | 126 +--- src/layers/osmPlaces/render/display.test.mjs | 68 ++- src/layers/shared/render/moverBank.js | 21 +- src/layers/shared/render/surfacePoints.js | 51 +- src/layers/streetLevel/render/display.js | 111 ++-- .../streetLevel/render/display.test.mjs | 103 ++++ src/main.js | 84 ++- src/maps/sentinel2.js | 3 +- src/standalone/engineSwitch.js | 4 + src/testSupport/fleetTickFixture.mjs | 106 ++++ src/tooling/importDirections.test.mjs | 1 - src/ui/shellFacade.js | 7 +- src/ui/shellFeedback.js | 4 +- src/ui/styles/controls.css | 13 + src/ui/toast.js | 6 +- src/ui/toastView.test.mjs | 20 + src/ui/views/CommandDockView.vue | 7 +- src/ui/wildlifeSightingCard.js | 2 +- src/ui/wildlifeSightingCard.test.mjs | 32 + 82 files changed, 2363 insertions(+), 1574 deletions(-) create mode 100644 build/panelAlias.test.mjs create mode 100644 scripts/qa-gbfs-names.test.mjs delete mode 100644 src/data/geoImportIconBudget.test.mjs delete mode 100644 src/data/geoImportRaisedPolygon.test.mjs delete mode 100644 src/globe/cesium/imageryStageCredit.test.mjs delete mode 100644 src/globe/cesium/imageryStageRequests.test.mjs delete mode 100644 src/globe/navara/cameraChanged.test.mjs delete mode 100644 src/globe/navara/flightInput.test.mjs delete mode 100644 src/globe/navara/inputPinch.test.mjs delete mode 100644 src/globe/navara/overlaySonar.test.mjs create mode 100644 src/testSupport/fleetTickFixture.mjs diff --git a/build/panel.js b/build/panel.js index 8c1d698d7..fc1a7e485 100644 --- a/build/panel.js +++ b/build/panel.js @@ -30,7 +30,7 @@ export function panelBuildConfig(config) { // 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$/, + find: /^(?:\.\/|.*\/app\/)navaraMode\.js$|^.*\/globe\/navara\/windFlowMesh\.js$/, replacement: fileURLToPath( new URL('./panelNavara.js', import.meta.url), ), diff --git a/build/panelAlias.test.mjs b/build/panelAlias.test.mjs new file mode 100644 index 000000000..ec2269d05 --- /dev/null +++ b/build/panelAlias.test.mjs @@ -0,0 +1,45 @@ +// A Vite build through the panel alias resolves renderers.js's relative +// import of Navara's wind mesh to the stub (scripts/build-panel.mjs uses the +// same alias). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { build } from 'vite'; +import { panelBuildConfig } from './panel.js'; + +test('a Vite build resolves the relative wind-mesh import through the panel alias', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'panel-alias-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(join(root, 'src/app/layers'), { recursive: true }); + mkdirSync(join(root, 'src/globe/navara'), { recursive: true }); + writeFileSync( + join(root, 'src/app/layers/renderers.js'), + "export const load = () => import('../../globe/navara/windFlowMesh.js');\n", + ); + writeFileSync( + join(root, 'src/globe/navara/windFlowMesh.js'), + 'export const real = true;\n', + ); + const { resolve } = panelBuildConfig({ plugins: [] }); + const [output] = await build({ + configFile: false, + root, + logLevel: 'silent', + resolve, + build: { + outDir: join(root, 'out'), + write: false, + lib: { + entry: join(root, 'src/app/layers/renderers.js'), + formats: ['es'], + fileName: 'x', + }, + rollupOptions: { output: { inlineDynamicImports: true } }, + }, + }); + const [chunk] = output.output; + assert.match(chunk.code, /Navara is unavailable in the panel/); + assert.doesNotMatch(chunk.code, /real = true/); +}); diff --git a/build/panelResources.test.mjs b/build/panelResources.test.mjs index 438226690..9eaa09c40 100644 --- a/build/panelResources.test.mjs +++ b/build/panelResources.test.mjs @@ -27,7 +27,10 @@ test('the panel replaces Navara entry points and retains browser aliases', () => ); 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( + '../../globe/navara/windFlowMesh.js'.replace(alias.find, alias.replacement), + alias.replacement, + ); assert.equal(alias.find.test('../../globe/cesium/windFlow.js'), false); assert.ok(config.resolve.alias.includes(browser.resolve.alias[0])); }); diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0bd95e562..b945dbbf0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -169,9 +169,14 @@ one application, and only the globe-bound parts move to the next engine. - **Choosing the engine.** A choice read from storage (no `?engine=`) that fails to start opens Cesium instead, stores Cesium in `gev.engine` and the address, and says so in a toast ("Globe engine failed to start. Running - Cesium: …"). An explicit `?engine=` that fails keeps the error and the - engine picker on the loading screen, so another choice retries without a - reload. Back and Forward to an address without `?engine=` use the engine + Cesium: …") once the shared view is restored; the toast stays 6 s. An + explicit `?engine=` that fails keeps the loading screen with the error + naming the engine ("Could not start Navara: …") and an engine picker + labelled "Choose an engine to try again.", focused on its first choice, so + another choice retries without a reload; a retry that starts keeps the + loading screen until its view is restored. The loading screen lifts only + after the start-up share-link restore (the camera has landed) and at least + 1 s. Back and Forward to an address without `?engine=` use the engine the page started with, not a `gev.engine` another tab has changed since. A switch that fails and leaves the previous engine running names that engine in its toast. The Globe Engine picker is a compact field that keeps @@ -201,8 +206,9 @@ one application, and only the globe-bound parts move to the next engine. skipped and named. Among its members: `surface.sampleTerrainMostDetailed` (terrain heights at the finest level, fetched where not loaded), `maps.terrainRevision()` (changes when the terrain shown is replaced), - `render.maxTextureSize()` and `MoverSet.failed()` (a model or atlas that - will not load). Navara also has Cesium's `contactSonarDiagnostics()`, + `render.maxTextureSize()`, `MoverSet.failed()` (a model or atlas that + will not load) and `camera.completeFlight()` (a running flight lands at + once, as Cesium's). Navara also has Cesium's `contactSonarDiagnostics()`, outside the contract. - **Navara's ground and imagery passes.** Navara draws ground fills and corridors in one screen pass classified on the scene depth @@ -274,8 +280,10 @@ one application, and only the globe-bound parts move to the next engine. - **The session owns the state.** Catalog, layer models, sources, selection, camera mode and URL state outlive a switch. A picker switch hands the outgoing camera's exact pose to the incoming one (the address rounds it to - 1e-4°, whole metres and whole degrees); a history visit takes the entry's - view. A layer that is a session model + 1e-4°, whole metres and whole degrees); a pick made during a camera flight + first lands it (`camera.completeFlight`, Cesium's completeFlight), so the + incoming engine stands at its destination. A history visit takes the + entry's view. A layer that is a session model gets `attachRenderer(viewer, { engine })` / `detachRenderer()` instead of being rebuilt, so its data is not fetched again. The outgoing globe is parked (`src/standalone/rendererCache.js`), so switching back within three minutes @@ -303,10 +311,14 @@ one application, and only the globe-bound parts move to the next engine. are measured, at most 16 and 4 ms a pass, again after the camera moves and tiles load; a height that holds is not written again, so a parked camera asks for no frames. Submarine cable landing markers stand on the surface by - the same rule and draw over the scene within 2.5 km of the camera. OSM Places refines heights only for places on - screen, within a per-pass budget, after camera moves and tile loads, and - drops implausible samples. Directions' A/B markers and Street Level's cones - move again once tiles load, under Google 3D too. Traffic's road heights are + the same rule and draw over the scene within 2.5 km of the camera. OSM + Places (2 m over the surface) and Street Level's cones and position marker + follow the same rule; a point not measured yet is screen-tested on the + loaded terrain (under Google 3D on Cesium, whose hidden globe loads none, + at the ground under the camera), and a Street Level sprite waits on the + loaded terrain until it is measured. Switching Google 3D on or off measures + these points again. Directions' A/B markers move again once tiles load, + under Google 3D too. Traffic's road heights are sampled again when the terrain changes (`maps.terrainRevision`), and cars on roads below the ellipsoid are not culled by the WGS84 horizon. The weather shell halves its images (to 1024 px wide) when the GPU's texture @@ -314,7 +326,11 @@ one application, and only the globe-bound parts move to the next engine. stays hidden after a switch, and the active camera's obstruction probe runs again once the new engine's surface is ready. A tracked aircraft's glTF that the engine did not load is loaded again in a new mover set after a - backoff and given up after a failure budget; far contacts in Cockpit draw + backoff and given up after a failure budget; a fleet model of Flights, + Military or Local ADS-B that failed gives its slot back at once (the + contact stays a glyph), and its glTF is fetched again in a new mover set + no sooner than 1.5 s later, when the next aircraft of that class is + admitted; far contacts in Cockpit draw as dots from their own sprite set. - **Layer state and cards after a switch.** A history visit across engines applies the visited entry's layer options that differ from the live @@ -2318,7 +2334,8 @@ translation entries do not interrupt loading of the other stations. This covers station names, not complete GBFS v3 support. `scripts/qa-gbfs-names.mjs` checks synthetic localized names through the real layer, station selection, keyboard dismissal, mobile cards and bike-share tool against -either a dev or preview server (`--url` selects the target). +either a dev or preview server (`--url` selects the target); the `--w2` +raster checks import source modules and need the dev server. The station reader addresses the proxy as `/api/gbfs/`; the feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while @@ -6308,8 +6325,8 @@ request viewbox and continues searching without that geographic preference. ### Import Your Own Files (October 2026) - UTILITIES ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools. -- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. `geoFileImport.js` and `geoImportKml.js` produce plain records; one `geoImportDisplay.js` draws them on ground, movers, lines and imagery on every engine, without loading Cesium. Ground paths follow great circles and retain segment gaps. Pins use square sprite canvases preserving Cesium's icon placement and styles; a file's icon looks share a budget of 16 million canvas pixels, so many distinct styles draw at the same size from lower-resolution textures. A KML heading turns with the camera's heading, staying measured from north. Ground and relative pins sample loaded terrain and, at camera heights below 250 km with a tile surface, `engine.surface.clampToHeight` for the pins in the window; absolute pins keep their height. The first sampling and every refresh run in batches of 256 that yield between them; refreshes go through the controller's serial queue after camera/map changes and terrain completion, retaining the last available height on misses. Ground pins ignore scene depth and are horizon-culled. Removal destroys every owned drawing set. -- **Drawing limits.** Raised polygons draw their fills (meshes) and outlines at their vertices' heights. Ground overlays take their colour's tint and alpha; a rotated one is turned on a canvas over the box around it, and an absolute one is raised to its altitude, as Cesium does (a relative one drapes, as on Cesium). Wall fills, extruded prisms, `gx:LatLonQuad`, a rotated overlay whose box passes a pole and a raised plain-colour overlay are counted as undrawn; wall top edges and polygon top geometry remain. Rhumb edges become great circles. Packed image overlays draw on both engines. A GeoJSON line of one point or a polygon of fewer than three distinct corners is not a record, so a file of only those is refused as having nothing to draw. +- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. `geoFileImport.js` and `geoImportKml.js` produce plain records; one `geoImportDisplay.js` draws them on ground, movers, lines and imagery on every engine, without loading Cesium. Ground paths follow great circles and retain segment gaps. Pins use square sprite canvases preserving Cesium's icon placement and styles; a file's icon looks share a budget of 16 million canvas pixels, so many distinct styles draw at the same size from lower-resolution textures. A KML heading turns with the camera's heading, staying measured from north. Ground and relative pins sample loaded terrain and, at camera heights below 250 km with a tile surface, `engine.surface.clampToHeight` for the pins in the window; absolute pins keep their height. The first sampling and every refresh run in batches of 256 that yield between them; refreshes go through the controller's serial queue after camera/map changes and terrain completion, retaining the last available height on misses; on a tile surface a pin outside the window keeps its last tile height, which the window test also uses. Ground pins ignore scene depth and are horizon-culled. Removal destroys every owned drawing set. +- **Drawing limits.** Raised polygons draw their fills (meshes triangulated in the plane tangent to the Earth, as Cesium's perPositionHeight) and outlines at their vertices' heights; a raised polygon of more than 128 rings is not drawn, and one of more than 4,096 vertices draws only its outlines (both counted as undrawn). Ground overlays take their colour's tint and alpha and stack by KML `drawOrder`, then document order (a file imported later draws over); a rotated one is turned on a canvas over the box around it; a file's tinted and turned canvases share a budget of 16 million pixels and equal ones are drawn once; an absolute image overlay is a textured mesh fixed at its altitude, as Cesium's rectangle at a height (a relative one drapes, as on Cesium). Wall fills, extruded prisms, `gx:LatLonQuad`, a rotated overlay whose box passes a pole and a raised plain-colour overlay are counted as undrawn; wall top edges and polygon top geometry remain. Rhumb edges become great circles. Packed image overlays draw on both engines. A GeoJSON line of one point or a polygon of fewer than three distinct corners is not a record, so a file of only those is refused as having nothing to draw. A hole of fewer than three distinct corners is dropped; a ground polygon the ground contract still refuses is counted as undrawn. - **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rectangular overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`. - **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged. - **Input handling.** Coordinates and GeoJSON geometry types are checked before drawing allocation. Only short plain names reach the record model; file IDs, custom CRS and styling properties are removed. KML feature/geometry IDs are removed while Style/StyleMap references remain. Local style references are trimmed, incomplete style pairs removed, hidden-folder flags normalized to the import's show control, and unsupported prototype-named elements removed. UTF-16 BOMs and common KML namespace export defects are handled. Empty drawable content is refused. A loader failure or timeout does not hold the next file indefinitely; teardown aborts pending waits. diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index 45e31ab5e..859db1b61 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -38,7 +38,9 @@ Instances move every frame without a rebuild. `depthTest` decides whether the scene hides them. A pick returns the instance's `pickId` when the set is `pickable`. `ready(index?)` says whether a model's glTF or a sprite atlas has loaded and `failed(index?)` whether it failed to: a failed set is not loaded -again, so a layer that retries makes a new set. +again, so a layer that retries makes a new set. Cesium loads a model per +instance: an index written with another `pickId` loads its own model, so an +instance moved into a failed index does not inherit the failure. ## Lines: polylines in the air diff --git a/scripts/module-analysis.mjs b/scripts/module-analysis.mjs index 0bcf31488..3831306c1 100644 --- a/scripts/module-analysis.mjs +++ b/scripts/module-analysis.mjs @@ -18,11 +18,11 @@ const browserGlobals = new Set([ /** * Inspect literal module dependencies and browser-platform references without - * executing code. With `dynamic`, also list the import() specifiers apart. + * executing code. With `dynamic`, also list the static (not import()) + * specifiers apart. */ export function analyzeModule(source, { dynamic = false, filename = '' } = {}) { const imports = []; - const dynamicImports = []; const staticImports = []; const browser = new Set(); function walk(node, parent, field) { @@ -41,9 +41,8 @@ export function analyzeModule(source, { dynamic = false, filename = '' } = {}) { 'Computed module imports are not allowed in runtime code', ); imports.push(node.source.value); - if (node.type === 'ImportExpression') - dynamicImports.push(node.source.value); - else staticImports.push(node.source.value); + if (node.type !== 'ImportExpression') + staticImports.push(node.source.value); } if (node.type === 'CallExpression' && node.callee?.name === 'require') throw new Error('Runtime modules must use ES imports'); @@ -86,7 +85,7 @@ export function analyzeModule(source, { dynamic = false, filename = '' } = {}) { return { imports, browser: [...browser], - ...(dynamic && { dynamicImports, staticImports }), + ...(dynamic && { staticImports }), }; } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f5127df16..73c6d923f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -47,6 +47,7 @@ "src/ui/views/index.js", "src/ui/views/CockpitView.vue", "src/ui/views/CommandDockView.vue", + "src/app/engineChoice.js", "src/ui/views/ContextView.vue", "src/ui/views/panels/RadioPanel.vue", "src/ui/views/panels/SdrCard.vue", @@ -2141,6 +2142,7 @@ "src/layers/shared/color.js", "src/globe/cameraMath.js", "src/layers/streetLevel/render/display.js", + "src/layers/shared/render/surfacePoints.js", "src/globe/contract.js" ], "external": ["pbf", "@mapbox/vector-tile", "mapillary-js"] diff --git a/scripts/qa-gbfs-names.mjs b/scripts/qa-gbfs-names.mjs index 610fe1107..863f919ab 100644 --- a/scripts/qa-gbfs-names.mjs +++ b/scripts/qa-gbfs-names.mjs @@ -2,6 +2,7 @@ /** * Keyless station-name regression against the real globe and bike-share tool. * Run with a dev or QA preview server and an explicitly assigned --url. + * --w2 adds raster checks that import source modules: dev server only. * GBFS documents are synthetic; no operator feed or provider key is required. */ import assert from 'node:assert/strict'; @@ -138,6 +139,13 @@ async function open(mode = 'normal') { { information, status, mode }, ); await page.goto(appUrl, { waitUntil: 'domcontentloaded' }); + if ( + extended && + !(await page.evaluate(() => + Boolean(document.querySelector('script[src="/@vite/client"]')), + )) + ) + throw new Error('--w2 requires a Vite dev server; omit --w2 for preview'); await page.waitForFunction( () => window.__godsEyeView?.dataManager && window.__godsEyeView?.voiceCommands, diff --git a/scripts/qa-gbfs-names.test.mjs b/scripts/qa-gbfs-names.test.mjs new file mode 100644 index 000000000..f0eeaa44b --- /dev/null +++ b/scripts/qa-gbfs-names.test.mjs @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync( + new URL('./qa-gbfs-names.mjs', import.meta.url), + 'utf8', +); +const openSource = source.slice( + source.indexOf('async function open('), + source.indexOf('async function nextFrame('), +); + +function fixture({ extended, dev }) { + const page = { + bringToFront: async () => {}, + on() {}, + setViewport: async () => {}, + evaluateOnNewDocument: async () => {}, + goto: async () => {}, + evaluate: async () => dev, + waitForFunction: async () => {}, + }; + const dependencies = { + browser: { newPage: async () => page }, + extended, + watchBrowserPage() {}, + report: {}, + appUrl: 'http://localhost:4000', + information: {}, + status: {}, + fromDegrees: () => ({}), + }; + return new Function( + ...Object.keys(dependencies), + `${openSource}\nreturn open;`, + )(...Object.values(dependencies)); +} + +test('preview rejects the optional dev-only raster checks before importing source modules', async () => { + await assert.rejects( + fixture({ extended: true, dev: false })(), + /--w2 requires a Vite dev server/, + ); +}); + +test('normal preview checks and extended dev checks remain available', async () => { + await fixture({ extended: false, dev: false })(); + await fixture({ extended: true, dev: true })(); +}); diff --git a/scripts/qa-strict-session.test.mjs b/scripts/qa-strict-session.test.mjs index 9d6f5b2c4..c6ac629b8 100644 --- a/scripts/qa-strict-session.test.mjs +++ b/scripts/qa-strict-session.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { quantile, slope } from './qa-strict-session.mjs'; import { strictArguments, waitForCameraAtRest } from './qa-strict-harness.mjs'; @@ -46,31 +47,67 @@ test('the runner hands each scenario its layers and its manifest path', () => { }); }); -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]; +// A page whose camera answers one sampled position (x, metres) and input +// state per waitForFunction poll; `polls()` counts the polls taken. +function cameraPage(t, positions, inputs, globals = {}) { let frame = 0; const engine = { - camera: { pose: () => ({ position: { x: poses[frame], y: 0, z: 6.4e6 } }) }, + camera: { + pose: () => ({ position: { x: positions[frame], y: 0, z: 6.4e6 } }), + }, input: { cameraInputsEnabled: () => inputs[frame] }, }; const previous = globalThis.window; - globalThis.window = { __godsEyeView: { engine } }; + globalThis.window = { __godsEyeView: { engine }, ...globals }; + t.after(() => { + globalThis.window = previous; + }); const page = { evaluate: async (fn) => fn(), async waitForFunction(fn) { - for (; frame < poses.length; frame++) if (fn()) return; + for (; frame < positions.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; - } + return { page, polls: () => frame }; +} + +test('the camera is at rest once a flight has landed and it stays still', async (t) => { + // A selection's flight holds the inputs off; once they are on, the camera + // still moves one more sample before it stays put. + const { page, polls } = cameraPage( + t, + [0, 400, 900, 900, 1250, 1250, 1250], + [false, false, false, false, true, true, true], + ); + await waitForCameraAtRest(page); + // Not while the flight holds the inputs (sample 3 repeats a pose), nor + // while the camera moves (sample 4). + assert.equal(polls(), 5); +}); + +test('qa-wildlife-switch waits for the focus flight even when the camera was already still', async (t) => { + // A rest the scenario recorded before the click is no rest of the flight. + const { page, polls } = cameraPage( + t, + [0, 0, 100, 500, 500], + [false, false, false, true, true], + { + __qaWildPose: { key: '0.0,0.0,6400000.0', at: performance.now() - 5000 }, + }, + ); + const source = readFileSync( + new URL('./qa-wildlife-switch.mjs', import.meta.url), + 'utf8', + ); + const still = new Function( + 'p', + 'waitForCameraAtRest', + `${source.slice( + source.indexOf(' const still ='), + source.indexOf(' // What the engine draws'), + )}\nreturn still;`, + )(page, waitForCameraAtRest); + await still(); + assert.equal(polls(), 4); }); diff --git a/scripts/qa-wildlife-switch.mjs b/scripts/qa-wildlife-switch.mjs index f423e1280..f3e7fa1e3 100644 --- a/scripts/qa-wildlife-switch.mjs +++ b/scripts/qa-wildlife-switch.mjs @@ -10,6 +10,7 @@ import { createStrict, localPreview, openPage, + waitForCameraAtRest, waitForEngine, } from './qa-strict-harness.mjs'; @@ -86,18 +87,7 @@ await strict.run({}, async (b) => { }, fromDegrees(WILDLIFE_VIEW.lon, WILDLIFE_VIEW.lat, WILDLIFE_VIEW.alt), ); - // Until the camera has held still for a second (a focus flight is over). - const still = () => - p.waitForFunction( - () => { - const pose = window.__godsEyeView.engine.camera.pose().position; - const key = [pose.x, pose.y, pose.z].map((v) => v.toFixed(1)).join(); - if (window.__qaWildPose?.key !== key) - window.__qaWildPose = { key, at: performance.now() }; - return performance.now() - window.__qaWildPose.at > 1000; - }, - { timeout: 30000, polling: 100 }, - ); + const still = () => waitForCameraAtRest(p, { timeout: 30000 }); // What the engine draws for the layer, and what its picker answers at each // sighting's anchor. const drawing = () => diff --git a/src/app/engineChoice.js b/src/app/engineChoice.js index 7d6b38ff7..c7b0b7218 100644 --- a/src/app/engineChoice.js +++ b/src/app/engineChoice.js @@ -4,9 +4,15 @@ export const ENGINE_STORAGE_KEY = 'gev.engine'; /** * The globe choices: Cesium, Navara at CSS pixels without MSAA (as Cesium * draws), and Navara HD at the device pixel ratio with MSAA 4 (on a 2x - * screen about 1 GiB more: 2.7 GiB against 1.8). + * screen about 1 GiB more: 2.7 GiB against 1.8). The names are product + * names, never translated. */ -export const ENGINE_CHOICES = Object.freeze(['cesium', 'navara', 'navara-hd']); +export const ENGINE_NAMES = Object.freeze({ + cesium: 'Cesium', + navara: 'Navara', + 'navara-hd': 'Navara HD', +}); +export const ENGINE_CHOICES = Object.freeze(Object.keys(ENGINE_NAMES)); /** The choice without one made, and the one a failed start falls back to. */ export const DEFAULT_ENGINE_CHOICE = ENGINE_CHOICES[0]; diff --git a/src/app/layerSession.js b/src/app/layerSession.js index 18cb616ed..a6be32659 100644 --- a/src/app/layerSession.js +++ b/src/app/layerSession.js @@ -15,7 +15,7 @@ * @property {{draw?: ReturnType, geoImport?: ReturnType}} tools Session tools. * @property {ReturnType | null} voice Session voice. * @property {{locale?: () => void, placeSearch?: object, sceneChrome?: boolean}} chrome Standalone startup and scene factory. - * @property {Map | null} handoffLayers Switch coordinator: outgoing layer intentions. + * @property {Map | null} handoffLayers Switch coordinator: outgoing layer intentions; visit restores the incoming address's options instead of keeping the live options. * @property {boolean} restoringView Switch coordinator: incoming address restoration. * @property {() => boolean} switching Switch coordinator: voice waits while busy. * @property {object | null} selectionContext Scene factory: selected record carried to the next attachment. diff --git a/src/app/mainStartup.test.mjs b/src/app/mainStartup.test.mjs index 5cd0e1635..c9c3b07b1 100644 --- a/src/app/mainStartup.test.mjs +++ b/src/app/mainStartup.test.mjs @@ -12,12 +12,18 @@ const settle = async () => { for (let i = 0; i < 15; i++) await new Promise((r) => setTimeout(r, 0)); }; -function boot(t, { search = '', failures = ['navara'] } = {}) { +function boot( + t, + { search = '', failures = ['navara'], initialRestorePromise } = {}, +) { document.body.innerHTML = '

'; const starts = []; const switches = []; const toasts = []; + const toastDurations = []; + const flushes = []; + const addresses = []; const stored = new Map([[choices.ENGINE_STORAGE_KEY, 'navara']]); const location = { search, href: `https://example.test/${search}` }; const localStorage = { @@ -46,11 +52,14 @@ function boot(t, { search = '', failures = ['navara'] } = {}) { search: location.search, storage: localStorage, }), - createEngineAddress: () => ({ - restore(kind) { - location.search = `?engine=${kind}`; - }, - }), + createEngineAddress: (options) => { + addresses.push(options); + return { + restore(kind) { + location.search = `?engine=${kind}`; + }, + }; + }, createSceneSwitcher: (options) => { switches.push(options); return { @@ -63,24 +72,46 @@ function boot(t, { search = '', failures = ['navara'] } = {}) { 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) } }, - }), - }), + createStandaloneApplication: ({ engineKind }) => { + const styleManager = { + initialRestorePromise, + shareLinkManager: { flush: () => flushes.push('share link') }, + _showToast(text, durationMs) { + toasts.push(text); + toastDurations.push(durationMs); + }, + }; + return { + 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: { + camera: { completeFlight: () => flushes.push('flight') }, + }, + }, + controls: { styleManager }, + }), + }; + }, console: { error() {} }, }; new Function(...Object.keys(deps), source)(...Object.values(deps)); - return { starts, switches, stored, toasts }; + return { + starts, + switches, + stored, + toasts, + toastDurations, + flushes, + addresses, + }; } test('a failed stored engine falls back to Cesium and remembers the recovery', async (t) => { @@ -92,6 +123,7 @@ test('a failed stored engine falls back to Cesium and remembers the recovery', a assert.deepEqual(f.toasts, [ 'Globe engine failed to start. Running Cesium: Engine failed', ]); + assert.deepEqual(f.toastDurations, [6000]); }); test('a failed explicit first engine offers a usable retry picker', async (t) => { @@ -100,7 +132,16 @@ test('a failed explicit first engine offers a usable retry picker', async (t) => assert.deepEqual(f.starts, ['navara']); const retry = document.querySelector('[data-engine="cesium"]'); assert.ok(retry); + assert.ok(document.activeElement === retry); + const status = document.querySelector('.loader-status'); + assert.equal(status.textContent, 'Could not start Navara: Engine failed'); + assert.equal(status.getAttribute('role'), 'alert'); + assert.equal( + document.getElementById('engine-switch-retry-label').textContent, + 'Choose an engine to try again.', + ); retry.click(); + assert.equal(status.getAttribute('role'), null); await settle(); assert.deepEqual(f.starts, ['navara', 'cesium']); assert.equal(f.switches[0].kind, 'cesium'); @@ -114,7 +155,60 @@ test('a recovered engine switch names the globe that remains running', async (t) recovered: true, kind: 'cesium', }); + await settle(); assert.deepEqual(f.toasts, [ 'Globe engine failed to start. Running Cesium: Initialization failed', ]); }); + +test('the startup cover stays until the share-link restore settles', async (t) => { + // start() has resolved; startApplicationChrome hides the cover once the + // share restore (initialRestorePromise) settles. + const f = boot(t, { search: '?engine=cesium', failures: [] }); + await settle(); + assert.deepEqual(f.starts, ['cesium']); + const loading = document.getElementById('loading-screen'); + assert.equal(loading.classList.contains('hidden'), false); + f.switches[0].onSettled(); + assert.equal(loading.classList.contains('hidden'), false); +}); + +test('the fallback notice waits for the restored view', async (t) => { + let restore; + const initialRestorePromise = new Promise((resolve) => { + restore = resolve; + }); + const f = boot(t, { initialRestorePromise }); + await settle(); + assert.deepEqual(f.starts, ['navara', 'cesium']); + assert.deepEqual(f.toasts, []); + restore(); + await settle(); + assert.deepEqual(f.toasts, [ + 'Globe engine failed to start. Running Cesium: Engine failed', + ]); +}); + +test('a successful retry keeps the failure cover until its view is restored', async (t) => { + let restore; + const initialRestorePromise = new Promise((resolve) => { + restore = resolve; + }); + boot(t, { search: '?engine=navara', initialRestorePromise }); + await settle(); + document.querySelector('[data-engine="cesium"]').click(); + await settle(); + const loading = document.getElementById('loading-screen'); + assert.equal(loading.classList.contains('hidden'), false); + restore(); + await settle(); + assert.equal(loading.classList.contains('hidden'), true); + assert.equal(document.querySelector('.engine-switch-retry'), null); +}); + +test('a pick made mid-flight lands the flight before the view is written', async (t) => { + const f = boot(t, { search: '?engine=cesium', failures: [] }); + await settle(); + f.addresses.find((options) => options.flush).flush(); + assert.deepEqual(f.flushes, ['flight', 'share link']); +}); diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 355733ee4..5082bc48e 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -1205,10 +1205,13 @@ export function createBhoteKoshiEventLayer({ try { if (_engine && !_viewer) { const revision = _engine.maps.terrainRevision(); - const heights = await _engine.surface.sampleTerrainMostDetailed( - observations.map(({ lon, lat }) => - Cesium.Cartographic.fromDegrees(lon, lat), + const heights = await waitForSignal( + _engine.surface.sampleTerrainMostDetailed( + observations.map(({ lon, lat }) => + Cesium.Cartographic.fromDegrees(lon, lat), + ), ), + signal, ); // Heights of a terrain replaced meanwhile are not the one shown. if (_engine.maps.terrainRevision() === revision) diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs index d9146e7b3..3e16b77d3 100644 --- a/src/data/bhoteKoshiEvent.test.mjs +++ b/src/data/bhoteKoshiEvent.test.mjs @@ -3211,7 +3211,7 @@ test('Bhote Koshi republishes its evidence cards to the incoming engine overlay' // 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 }) { +async function navaraEvidenceCard({ sample, signal }) { const event = JSON.parse(await readFile(eventUrl, 'utf8')); installEventDom(); globalThis.window = { setTimeout: () => 1, clearTimeout() {} }; @@ -3251,7 +3251,7 @@ async function navaraEvidenceCard({ sample }) { renderHost: { request() {}, hold() {}, release() {} }, }); await layer.init(null, { engine }); - await layer.enable(null, { origin: 'local-restore' }); + await layer.enable(null, { origin: 'local-restore', signal }); const [first] = event.evidenceSpine; const card = entries.find( (entry) => entry.id === `evidence-card-${first.id}`, @@ -3281,6 +3281,42 @@ test('Navara evidence anchors stand on the most detailed terrain', async () => { } }); +test('Navara evidence sampling stops waiting when enable is cancelled', async () => { + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const controller = new AbortController(); + const started = Promise.withResolvers(); + const sample = Promise.withResolvers(); + const enabling = navaraEvidenceCard({ + signal: controller.signal, + sample: () => { + started.resolve(); + return sample.promise; + }, + }); + let timer; + try { + await started.promise; + controller.abort(); + const outcome = await Promise.race([ + enabling.then( + () => 'enabled', + (error) => error.name, + ), + new Promise((resolve) => { + timer = setTimeout(() => resolve('still waiting'), 100); + }), + ]); + assert.equal(outcome, 'AbortError'); + } finally { + clearTimeout(timer); + sample.resolve([]); + await enabling.catch(() => {}); + 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; diff --git a/src/data/geoImportDisplay.js b/src/data/geoImportDisplay.js index 78e6edd95..1a7b22235 100644 --- a/src/data/geoImportDisplay.js +++ b/src/data/geoImportDisplay.js @@ -9,18 +9,18 @@ // 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, 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. +// - ground overlays: image layers, or textured meshes at absolute altitudes, +// tinted and turned on a canvas when the KML says so; 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 { polygonShape, rectangleShape } from '../scenes/dataPacks/shapes.js'; import { composeIcon } from './geoImportPins.js'; import { recordFootprint } from './geoImportRecords.js'; @@ -37,8 +37,15 @@ 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; +// Canvas pixels every tinted or turned ground overlay of a file shares. +const OVERLAY_PIXEL_BUDGET = 16 * 1024 * 1024; // The longest side of a tinted or turned ground overlay's canvas. const MAX_OVERLAY_SIDE = 4096; +// earcut runs on the main thread and grows quadratically with holes and +// vertices: a raised polygon past these draws no fill (past the rings, not +// even its outlines, one line each). +const MAX_RAISED_RINGS = 128; +const MAX_RAISED_VERTICES = 4096; let serial = 0; // Each engine's imported point sets, left out of each other's ground. const pointSets = new WeakMap(); @@ -85,7 +92,7 @@ function overlayBox({ rectangle, rotation = 0 }) { // 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) { +async function overlayImage(overlay, loadImage, createCanvas, maxSide, cache) { const tint = overlay.color.slice(0, 3); const { box, center, angle } = overlayBox(overlay); if (tint.every((value) => value === 1) && !angle) @@ -102,8 +109,14 @@ async function overlayImage(overlay, loadImage, createCanvas) { east: center[0] + spanX / 2, north: center[1] + spanY / 2, }; - if (rectangle.south < -90 || rectangle.north > 90 || spanX >= 360) + if (angle && (rectangle.south < -90 || rectangle.north > 90 || spanX >= 360)) return null; + const wrap = (lon) => (lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon); + const bounds = angle + ? { ...rectangle, west: wrap(rectangle.west), east: wrap(rectangle.east) } + : overlay.rectangle; + const key = JSON.stringify([overlay.url, tint, angle, width / height]); + if (cache.has(key)) return { image: cache.get(key), rectangle: bounds }; let image; try { image = await loadImage(overlay.url); @@ -116,13 +129,13 @@ async function overlayImage(overlay, loadImage, createCanvas) { image.naturalHeight || image.height, ]; // Pixels per degree: one scale on both axes when turned, so the image - // turns in degrees as Cesium's does. + // turns in degrees as Cesium's does (the canvas budget bounds its size). 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), + maxSide / (spanX * scaleX), + maxSide / (spanY * scaleY), ); [scaleX, scaleY] = [scaleX * fit, scaleY * fit]; const canvas = createCanvas(); @@ -146,15 +159,8 @@ async function overlayImage(overlay, loadImage, createCanvas) { 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), - }, - }; + cache.set(key, canvas); + return { image: canvas, rectangle: bounds }; } // KML headings are from north (Cesium's billboard on the Earth's axis): @@ -240,27 +246,22 @@ export async function drawImportedFile( const fills = []; for (const polygon of records.polygons) { let any = false; + if (polygon.raised && polygon.rings.length > MAX_RAISED_RINGS) { + kinds.complexPolygon = (kinds.complexPolygon ?? 0) + 1; + continue; + } 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) { + const vertices = polygon.rings.reduce((n, ring) => n + ring.length, 0); + const shape = + vertices <= MAX_RAISED_VERTICES && polygonShape(polygon.rings); + if (vertices > MAX_RAISED_VERTICES) + kinds.complexPolygon = (kinds.complexPolygon ?? 0) + 1; + if (shape) { 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, + positions: shape.attributes.position.values, + indices: shape.indices, color: polygon.fill, show: shown, }), @@ -275,7 +276,7 @@ export async function drawImportedFile( if (item) { fills.push(item); any = true; - } + } else kinds.polygon = (kinds.polygon ?? 0) + 1; } if (polygon.raised && polygon.outline) for (const ring of polygon.rings) { @@ -293,18 +294,64 @@ export async function drawImportedFile( } if (any) drawn++; } - for (const overlay of records.overlays) { + const overlayCache = new Map(); + const overlayImages = new Map(); + const overlaySide = Math.min( + MAX_OVERLAY_SIDE, + Math.floor( + Math.sqrt(OVERLAY_PIXEL_BUDGET / Math.max(1, records.overlays.length)), + ), + ); + // KML drawOrder, then the document's: equal layer orders draw in + // creation order, and a file imported later over this one. + const overlays = [...records.overlays].sort( + (a, b) => (a.drawOrder ?? 0) - (b.drawOrder ?? 0), + ); + for (const overlay of 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); + const prepared = await overlayImage( + overlay, + loadImage, + createCanvas, + overlaySide, + overlayCache, + ); if (!prepared) { kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1; continue; } + if (raise) { + let image = prepared.image ?? overlayImages.get(overlay.url); + if (!image) { + try { + image = await loadImage(overlay.url); + overlayImages.set(overlay.url, image); + } catch { + kinds.raisedOverlay = (kinds.raisedOverlay ?? 0) + 1; + continue; + } + } + const { west, south, east, north } = prepared.rectangle; + const shape = rectangleShape([west, south, east, north], raise); + meshes.push( + engine.meshes.createMesh({ + id: `${prefix}-overlay-${meshes.length}`, + positions: shape.attributes.position.values, + indices: shape.indices, + uv: shape.attributes.st.values, + image, + color: [1, 1, 1, Math.min(0.99, overlay.color[3])], + show: shown, + }), + ); + drawn++; + continue; + } layers.push( engine.imagery.createLayer({ id: `${prefix}-overlay-${layers.length}`, @@ -313,7 +360,6 @@ export async function drawImportedFile( alpha: Math.min(0.99, overlay.color[3]), show: shown, order: OVERLAY_ORDER, - ...(raise ? { raise } : {}), }), ); drawn++; @@ -336,7 +382,12 @@ export async function drawImportedFile( [box.west, box.north], ].map(turn), ]; - fills.push({ rings, fill: overlay.color }); + const fill = checked('polygon', { rings, fill: overlay.color }); + if (!fill) { + kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1; + continue; + } + fills.push(fill); drawn++; } if (fills.length) { @@ -345,10 +396,10 @@ export async function drawImportedFile( kind: 'polygon', order: POLYGON_ORDER, }); + groundSets.push(set); set.replace(fills); set.commit(); set.show(shown); - groundSets.push(set); } const corridors = []; @@ -379,10 +430,10 @@ export async function drawImportedFile( kind: 'corridor', order: LINE_ORDER, }); + groundSets.push(set); set.replace(corridors); set.commit(); set.show(shown); - groundSets.push(set); } for (const point of records.points) { @@ -507,6 +558,9 @@ export async function drawImportedFile( ? Math.max(0, terrain) : terrain : (pin.ground ?? 0); + // On tiles the last height found stays until the tiles answer again: + // the terrain under them is not where the pin stands. + if (tiles) ground = pin.ground ?? ground; if ( tiles && inWindow(geo.cartesian3.fromDegrees(pin.lon, pin.lat, ground), size) diff --git a/src/data/geoImportDisplay.test.mjs b/src/data/geoImportDisplay.test.mjs index 85c5a795a..2a3cd7cea 100644 --- a/src/data/geoImportDisplay.test.mjs +++ b/src/data/geoImportDisplay.test.mjs @@ -6,10 +6,15 @@ import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs'; import { drawImportedFile } from './geoImportDisplay.js'; import { emptyRecords, geoJsonRecords } from './geoImportRecords.js'; -// A 2D context that only records what draws. +// A 2D context that only records what draws; its image data is one pixel. const createCanvas = () => ({ getContext: () => - new Proxy({}, { get: (_, key) => (key in {} ? undefined : () => {}) }), + new Proxy( + { getImageData: () => ({ data: new Uint8ClampedArray(4) }) }, + { + get: (target, key) => target[key] ?? (key in {} ? undefined : () => {}), + }, + ), }); for (const make of GROUND_HARNESSES) { @@ -234,6 +239,49 @@ for (const make of GROUND_HARNESSES) { }, ); + displayTest( + 'a pin on the tiles keeps its height while out of the window', + async ({ engine }) => { + engine.imagery.surface = () => 'tiles'; + engine.camera.height = () => 1000; + engine.projector.horizonVisible = () => true; + let visible = true; + engine.projector.worldToWindow = () => (visible ? { x: 1, y: 1 } : null); + engine.surface.clampToHeight = (position) => { + const at = geo.cartographic.fromCartesian(position); + return geo.cartesian3.fromRadians(at.longitude, at.latitude, 500); + }; + const drawing = await drawImportedFile( + engine, + geoJsonRecords({ type: 'Point', coordinates: [1, 2] }, '#39d0ff'), + { createCanvas }, + ); + const height = () => Math.round(drawing.footprint()[0][2]); + assert.equal(height(), 500); + visible = false; + await drawing.refreshGround(); + assert.equal(height(), 500, 'the measured tile height was replaced'); + let projected; + engine.projector.worldToWindow = (position) => { + projected = Math.round(geo.cartographic.fromCartesian(position).height); + return projected >= 500 ? { x: 1, y: 1 } : null; + }; + engine.surface.clampToHeight = () => + geo.cartesian3.fromDegrees(1, 2, 700); + await drawing.refreshGround(); + assert.equal(projected, 500, 'visibility uses the last measured height'); + assert.equal(height(), 700); + engine.surface.clampToHeight = () => undefined; + await drawing.refreshGround(); + assert.equal( + height(), + 700, + 'a missing tile sample keeps the last height', + ); + drawing.destroy(); + }, + ); + displayTest( 'fills keep holes, raised outlines and plain overlays', async ({ engine, set }) => { @@ -288,6 +336,240 @@ for (const make of GROUND_HARNESSES) { }, ); + displayTest( + 'icon variants of one file stay within a canvas budget', + async ({ engine }) => { + const records = emptyRecords(); + for (let i = 0; i < 2000; i++) + records.points.push({ + position: [1, 2, 0], + altitude: 'ground', + icon: { image: { url: 'blob:icon' }, scale: 10 + i / 1000 }, + }); + const canvases = []; + const drawing = await drawImportedFile(engine, records, { + createCanvas: () => { + const canvas = createCanvas(); + canvases.push(canvas); + return canvas; + }, + loadImage: async () => ({ width: 32, height: 32 }), + pixelRatio: 2, + }); + const bytes = canvases.reduce((n, c) => n + c.width * c.height * 4, 0); + assert.ok(bytes <= 64 * 2 ** 20, `canvas bytes ${bytes / 2 ** 20} MiB`); + drawing.destroy(); + }, + ); + + displayTest('a raised polygon fill keeps its heights', async ({ engine }) => { + const meshes = []; + const create = engine.meshes.createMesh; + engine.meshes.createMesh = (options) => { + meshes.push(options); + return create(options); + }; + const records = emptyRecords(); + records.polygons.push({ + 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 drawing = await drawImportedFile(engine, records, { createCanvas }); + assert.equal(engine.ground.list().length, 0); + assert.equal(meshes.length, 1); + assert.equal(meshes[0].positions[0], 6379137); + assert.equal(meshes[0].indices.length, 3); + drawing.destroy(); + }); + + displayTest( + 'a raised polygon whose vertices share one latitude keeps its fill', + async ({ engine }) => { + const records = emptyRecords(); + records.polygons.push({ + rings: [ + [ + [0, 85, 100000], + [120, 85, 100000], + [-120, 85, 100000], + ], + ], + raised: true, + fill: [0, 1, 0, 0.6], + outline: null, + }); + const drawing = await drawImportedFile(engine, records, { createCanvas }); + assert.equal(engine.meshes.list().length, 1, 'no fill mesh'); + drawing.destroy(); + }, + ); + + displayTest( + 'a raised polygon with 10 000 holes does not block the import', + async ({ engine }) => { + const records = emptyRecords(); + const outer = Array.from({ length: 1000 }, (_, i) => { + const a = (i / 1000) * 2 * Math.PI; + return [10 * Math.cos(a), 10 * Math.sin(a), 1000]; + }); + const holes = []; + for (let i = 0; i < 100; i++) + for (let j = 0; j < 100; j++) { + const [x, y, d] = [-6 + 0.12 * i, -6 + 0.12 * j, 0.03]; + holes.push([ + [x, y, 1000], + [x + d, y, 1000], + [x + d, y + d, 1000], + [x, y + d, 1000], + ]); + } + records.polygons.push({ + rings: [outer, ...holes], + raised: true, + fill: [0, 1, 0, 0.6], + outline: { color: [1, 1, 1, 1], widthPx: 1 }, + }); + const started = performance.now(); + const drawing = await drawImportedFile(engine, records, { createCanvas }); + const ms = Math.round(performance.now() - started); + assert.ok(ms < 1000, `the import step took ${ms} ms (41 000 vertices)`); + assert.deepEqual(drawing.report, { + drawn: 0, + undrawn: 1, + kinds: { complexPolygon: 1 }, + }); + assert.equal(engine.meshes.list().length + engine.lines.list().length, 0); + drawing.destroy(); + }, + ); + + displayTest( + 'a raised fill past the vertex bound is counted, its outline drawn', + async ({ engine }) => { + const records = emptyRecords(); + const outer = Array.from({ length: 5000 }, (_, i) => { + const angle = (i * 2 * Math.PI) / 5000; + return [10 * Math.cos(angle), 10 * Math.sin(angle), 1000]; + }); + records.polygons.push({ + rings: [outer], + raised: true, + fill: [1, 0, 0, 1], + outline: { color: [1, 1, 1, 1], widthPx: 1 }, + }); + const drawing = await drawImportedFile(engine, records, { createCanvas }); + assert.deepEqual(drawing.report, { + drawn: 1, + undrawn: 1, + kinds: { complexPolygon: 1 }, + }); + assert.equal(engine.meshes.list().length, 0); + assert.equal(engine.lines.list().length, 1); + drawing.destroy(); + }, + ); + + displayTest( + 'a polygon with a degenerate hole draws its outer ring', + async ({ engine }) => { + const records = geoJsonRecords( + { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0], + ], + [ + [1, 1], + [1.1, 1.1], + [1, 1], + ], + ], + }, + '#39d0ff', + ); + assert.equal(records.polygons[0].rings.length, 1); + const drawing = await drawImportedFile(engine, records, { createCanvas }); + assert.deepEqual(drawing.report, { drawn: 1, undrawn: 0, kinds: {} }); + drawing.destroy(); + }, + ); + + displayTest( + 'a polygon the ground contract rejects is counted undrawn', + async ({ engine }) => { + const records = geoJsonRecords( + { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [2, 0], + [2, 2], + ], + ], + }, + '#39d0ff', + ); + records.polygons[0].rings.push([ + [1, 1], + [1.1, 1.1], + [1, 1], + ]); + const drawing = await drawImportedFile(engine, records, { createCanvas }); + assert.deepEqual(drawing.report, { + drawn: 0, + undrawn: 1, + kinds: { polygon: 1 }, + }); + drawing.destroy(); + }, + ); + + for (const kind of ['Polygon', 'LineString']) + displayTest( + `a failed ${kind} ground set is released`, + async ({ engine }) => { + const create = engine.ground.createSet; + engine.ground.createSet = (options) => { + const set = create(options); + set.replace = () => { + throw new Error('replacement failed'); + }; + return set; + }; + const positions = [ + [0, 0], + [2, 0], + [2, 2], + ]; + const records = geoJsonRecords( + { + type: kind, + coordinates: kind === 'Polygon' ? [positions] : positions, + }, + '#39d0ff', + ); + await assert.rejects( + drawImportedFile(engine, records, { createCanvas }), + /replacement failed/, + ); + assert.equal(engine.ground.list().length, 0); + }, + ); + displayTest( 'failed icon composition releases geometry already created', async ({ engine }) => { @@ -384,6 +666,39 @@ for (const make of GROUND_HARNESSES) { return layers; }; + displayTest( + 'overlay drawOrder ranks images stably above the raster layers', + async ({ engine }) => { + const layers = overlayLayer(engine); + const records = emptyRecords(); + records.overlays = [10, -2, undefined, 10].map((drawOrder, index) => ({ + url: `blob:overlay-${index}`, + drawOrder, + color: [1, 1, 1, 1], + rectangle: { west: 0, south: 0, east: 1, north: 1 }, + })); + const drawing = await drawImportedFile(engine, records, { createCanvas }); + const later = emptyRecords(); + later.overlays = [{ ...records.overlays[0], url: 'blob:later' }]; + later.overlays[0].drawOrder = -5; + const over = await drawImportedFile(engine, later, { createCanvas }); + const urls = new Map(layers.map(({ id, source }) => [id, source.url])); + assert.deepEqual( + engine.imagery.list().map(({ id }) => urls.get(id)), + [ + 'blob:overlay-1', + 'blob:overlay-2', + 'blob:overlay-0', + 'blob:overlay-3', + 'blob:later', + ], + 'bottom first; a file imported later draws over this one', + ); + over.destroy(); + drawing.destroy(); + }, + ); + displayTest( 'colored overlays multiply image RGB and retain layer alpha', async ({ engine }) => { @@ -502,10 +817,194 @@ for (const make of GROUND_HARNESSES) { ); displayTest( - 'an absolute overlay is raised to its altitude, a relative one drapes', + 'a turned plain-colour overlay past the pole does not fail the file', + async ({ engine }) => { + const records = geoJsonRecords( + { type: 'Point', coordinates: [1, 2] }, + '#39d0ff', + ); + records.overlays.push({ + url: null, + color: [1, 0, 0, 0.5], + rectangle: { west: 0, south: 80, east: 30, north: 89 }, + rotation: 45, + }); + const drawing = await drawImportedFile(engine, records, { createCanvas }); + assert.deepEqual(drawing.report, { + drawn: 1, + undrawn: 1, + kinds: { rotatedOverlay: 1 }, + }); + assert.equal(engine.ground.list().length, 0); + drawing.destroy(); + }, + ); + + displayTest( + 'a tinted, unturned overlay over all longitudes still draws', async ({ engine }) => { const layers = overlayLayer(engine); const records = emptyRecords(); + records.overlays.push({ + url: 'blob:overlay', + color: [1, 0, 0, 1], + rectangle: { west: -180, south: -90, east: 180, north: 90 }, + rotation: 0, + }); + const drawing = await drawImportedFile(engine, records, { + createCanvas, + loadImage: async () => ({ width: 64, height: 32 }), + }); + assert.deepEqual(drawing.report.kinds, {}, 'counted undrawn'); + assert.equal(layers.length, 1); + assert.deepEqual( + { ...layers[0].source.rectangle }, + { west: -180, south: -90, east: 180, north: 90 }, + ); + drawing.destroy(); + }, + ); + + // Canvases made, their pixels counted. + const countedCanvases = () => { + const canvases = []; + return { + canvases, + pixels: () => canvases.reduce((n, c) => n + c.width * c.height, 0), + createCanvas: () => { + const canvas = createCanvas(); + canvases.push(canvas); + return canvas; + }, + }; + }; + const OVERLAY_PIXELS = 16 * 1024 * 1024; + + displayTest( + 'turned overlays sharing one small image share one bounded canvas', + async ({ engine }) => { + const counted = countedCanvases(); + const records = emptyRecords(); + for (let i = 0; i < 20; i++) + records.overlays.push({ + url: 'blob:overlay', + color: [1, 1, 1, 1], + rectangle: { west: i, south: 10, east: i + 10, north: 10.01 }, + rotation: 45, + }); + const drawing = await drawImportedFile(engine, records, { + createCanvas: counted.createCanvas, + loadImage: async () => ({ width: 64, height: 64 }), + }); + assert.equal(drawing.report.drawn, 20); + assert.equal(counted.canvases.length, 1, 'equal looks share a canvas'); + // Each of 20 overlays may hold a twentieth of the budget. + const side = Math.floor(Math.sqrt(OVERLAY_PIXELS / 20)); + const [canvas] = counted.canvases; + assert.ok( + canvas.width <= side && canvas.height <= side, + `${canvas.width}`, + ); + drawing.destroy(); + }, + ); + + displayTest( + 'distinct tints of a large image stay within the canvas budget', + async ({ engine }) => { + const counted = countedCanvases(); + const records = emptyRecords(); + for (let i = 0; i < 100; i++) + records.overlays.push({ + url: 'blob:overlay', + color: [1, i / 100, 0, 1], + rectangle: { west: 0, south: 0, east: 1, north: 1 }, + rotation: 0, + }); + const drawing = await drawImportedFile(engine, records, { + createCanvas: counted.createCanvas, + loadImage: async () => ({ width: 4096, height: 4096 }), + }); + assert.equal(drawing.report.drawn, 100); + assert.equal(counted.canvases.length, 100); + assert.ok( + counted.pixels() <= OVERLAY_PIXELS, + `${Math.round((counted.pixels() * 4) / 2 ** 20)} MiB of canvas RGBA`, + ); + drawing.destroy(); + }, + ); + + displayTest( + 'a shared overlay canvas keeps each rectangle and its aspect ratio', + async ({ engine }) => { + const counted = countedCanvases(); + const layers = overlayLayer(engine); + const records = emptyRecords(); + for (const rectangle of [ + { west: 0, south: 0, east: 2, north: 1 }, + { west: 10, south: 0, east: 12, north: 1 }, + { west: 0, south: 0, east: 1, north: 2 }, + ]) + records.overlays.push({ + url: 'blob:overlay', + color: [1, 1, 1, 1], + rectangle, + rotation: 45, + }); + const drawing = await drawImportedFile(engine, records, { + createCanvas: counted.createCanvas, + loadImage: async () => ({ width: 64, height: 32 }), + }); + assert.equal(counted.canvases.length, 2); + assert.equal(layers[0].source.image, layers[1].source.image); + assert.notEqual(layers[0].source.image, layers[2].source.image); + assert.ok( + Math.abs( + layers[1].source.rectangle.west - + layers[0].source.rectangle.west - + 10, + ) < 1e-9, + ); + drawing.destroy(); + }, + ); + + displayTest( + 'a turned overlay keeps every source pixel of an image squeezed on one axis', + async ({ engine }) => { + const { calls, canvas } = recordingCanvas(); + const records = emptyRecords(); + // 32 pixels a degree across, 64 up: both axes draw at 64. + records.overlays.push({ + url: 'blob:overlay', + color: [1, 1, 1, 1], + rectangle: { west: 0, south: 0, east: 2, north: 1 }, + rotation: 30, + }); + const drawing = await drawImportedFile(engine, records, { + createCanvas: () => canvas, + loadImage: async () => ({ width: 64, height: 64 }), + }); + const [, , , width, height] = calls.find( + ([name]) => name === 'drawImage', + ); + assert.deepEqual([width, height], [128, 64]); + drawing.destroy(); + }, + ); + + displayTest( + 'an absolute overlay stays at its altitude while a relative one drapes', + async ({ engine }) => { + const layers = overlayLayer(engine); + const meshes = []; + const create = engine.meshes.createMesh; + engine.meshes.createMesh = (options) => { + meshes.push(options); + return create(options); + }; + const records = emptyRecords(); for (const altitudeMode of ['absolute', 'relative']) records.overlays.push({ url: 'blob:overlay', @@ -515,10 +1014,58 @@ for (const make of GROUND_HARNESSES) { rectangle: { west: 0, south: 0, east: 1, north: 1 }, rotation: 0, }); - const drawing = await drawImportedFile(engine, records, { createCanvas }); + const drawing = await drawImportedFile(engine, records, { + createCanvas, + loadImage: async () => ({ width: 32, height: 32 }), + }); assert.deepEqual(drawing.report, { drawn: 2, undrawn: 0, kinds: {} }); - assert.equal(layers[0].raise, 1000); - assert.equal(layers[1].raise, undefined); + assert.equal(meshes.length, 1); + assert.equal(layers.length, 1); + assert.equal(layers[0].raise, undefined); + const mesh = meshes[0]; + assert.equal(mesh.image.width, 32); + assert.equal(mesh.uv.length, (mesh.positions.length / 3) * 2); + // A fixed height, not imagery's shell that rises with the camera. + for (let i = 0; i < mesh.positions.length; i += 3) { + const position = geo.cartesian3.create( + ...mesh.positions.slice(i, i + 3), + ); + const { height } = geo.cartographic.fromCartesian(position); + assert.ok(Math.abs(height - 1000) < 0.01, `${height}`); + } + drawing.destroy(); + assert.equal(engine.meshes.list().length, 0); + }, + ); + + displayTest( + 'absolute overlays share decoded images and report missing ones', + async ({ engine }) => { + const records = emptyRecords(); + for (const url of ['blob:overlay', 'blob:overlay', 'blob:missing']) + records.overlays.push({ + url, + altitudeMode: 'absolute', + altitude: 1000, + color: [1, 1, 1, 1], + rectangle: { west: 0, south: 0, east: 1, north: 1 }, + }); + let loads = 0; + const drawing = await drawImportedFile(engine, records, { + createCanvas, + loadImage: async (url) => { + loads++; + if (url === 'blob:missing') throw new Error('image unavailable'); + return { width: 32, height: 32 }; + }, + }); + assert.equal(loads, 2); + assert.equal(engine.meshes.list().length, 2); + assert.deepEqual(drawing.report, { + drawn: 2, + undrawn: 1, + kinds: { raisedOverlay: 1 }, + }); drawing.destroy(); }, ); diff --git a/src/data/geoImportIconBudget.test.mjs b/src/data/geoImportIconBudget.test.mjs deleted file mode 100644 index 53e0a9f55..000000000 --- a/src/data/geoImportIconBudget.test.mjs +++ /dev/null @@ -1,97 +0,0 @@ -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 7fc9f8a12..4b27bbeb2 100644 --- a/src/data/geoImportKml.js +++ b/src/data/geoImportKml.js @@ -9,7 +9,11 @@ // overlays as image rectangles. Labels are not drawn (the import never // drew them); walls and extruded polygons are counted in `undrawn`. -import { emptyRecords, importFeatureName } from './geoImportRecords.js'; +import { + emptyRecords, + hasRingVertices, + importFeatureName, +} from './geoImportRecords.js'; // KmlDataSource's billboard: 32 px, scaled by camera distance. const BILLBOARD_SIZE = 32; @@ -328,7 +332,7 @@ function readGeometry(geometry, style, name, records) { coordinates(first(ring, 'coordinates')), ), ) - .filter((ring) => ring.length); + .filter(hasRingVertices); const poly = style.poly ?? { outline: true, outlineColor: WHITE }; const raised = altitude !== 'ground'; if (raised && extrude) @@ -379,6 +383,7 @@ function readGroundOverlay(el, records) { url, rectangle, color, + drawOrder: numberOf(el, 'drawOrder') ?? 0, 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 fe93d67ca..ee43d21c3 100644 --- a/src/data/geoImportKml.test.mjs +++ b/src/data/geoImportKml.test.mjs @@ -412,3 +412,41 @@ test('GroundOverlay retains altitude and altitude mode for drawing diagnostics', assert.equal(records.overlays[0].altitude, 1000); assert.equal(records.overlays[0].altitudeMode, 'absolute'); }); + +test('GroundOverlay keeps drawOrder, including negatives and the default', () => { + const records = readKmlRecords( + prepared( + kml( + [10, -2, null, 10] + .map( + (order) => ` + + ${order === null ? '' : `${order}`} + 0101 + `, + ) + .join(''), + ), + ), + ); + assert.deepEqual( + records.overlays.map((overlay) => overlay.drawOrder), + [10, -2, 0, 10], + ); +}); + +test('KML polygons discard degenerate holes and retain valid ones', () => { + const records = readKmlRecords( + prepared( + kml(` + + 0,0 2,0 2,2 0,2 0,0 + 1,1 1.1,1.1 1,1 + 1,1 1.1,1.1 1,1 1.1,1.1 + 0.5,0.5 0.6,0.5 0.6,0.6 0.5,0.5 + `), + ), + ); + assert.equal(records.polygons[0].rings.length, 2); + assert.deepEqual(records.polygons[0].rings[1][0], [0.5, 0.5, 0]); +}); diff --git a/src/data/geoImportRaisedPolygon.test.mjs b/src/data/geoImportRaisedPolygon.test.mjs deleted file mode 100644 index 127cd051b..000000000 --- a/src/data/geoImportRaisedPolygon.test.mjs +++ /dev/null @@ -1,112 +0,0 @@ -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 9d1853c62..1f2d47e9d 100644 --- a/src/data/geoImportRecords.js +++ b/src/data/geoImportRecords.js @@ -17,8 +17,8 @@ // @property {ImportedOverlay[]} overlays // @property {Object} undrawn What the file has that no // engine draws, by kind (`wall`, `extruded`, `latLonQuad`), counted; -// the display adds the overlays it cannot draw (`rotatedOverlay`, -// `raisedOverlay`). +// the display adds overlays and polygons it cannot draw (`rotatedOverlay`, +// `raisedOverlay`, `polygon`, `complexPolygon`). // // @typedef {object} ImportedPoint // @property {number[]} position @@ -63,6 +63,7 @@ // @property {string|null} url Its image, or null for a plain colour. // @property {{ west: number, south: number, east: number, north: number }} rectangle // @property {number[]} color Multiplies the image; its alpha fades it. +// @property {number} drawOrder Higher overlays draw over lower ones. // @property {number} rotation Degrees counter-clockwise (KML LatLonBox). // @property {number} altitude Metres (KML altitude); Cesium raises an // absolute overlay to it. @@ -99,6 +100,10 @@ export const recordCount = (records) => records.polygons.length + records.overlays.length; +/** A ring needs three different ground positions to enclose a fill. */ +export const hasRingVertices = (ring) => + new Set(ring?.map(([lon, lat]) => `${lon},${lat}`)).size >= 3; + /** `#rrggbb` as rgba in 0..1, with Cesium.Color.fromCssColorString's bytes. */ export function rgbaOfHex(css, alpha = 1) { const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec( @@ -175,13 +180,10 @@ export function geoJsonRecords(geojson, color) { color: stroke, widthPx: GEOJSON_STROKE_PX, }); - else if ( - kind === 'Polygon' && - new Set(part[0]?.map(([lon, lat]) => `${lon},${lat}`)).size >= 3 - ) + else if (kind === 'Polygon' && hasRingVertices(part[0])) records.polygons.push({ name, - rings: part.map((ring) => ring.map(position)), + rings: part.filter(hasRingVertices).map((ring) => ring.map(position)), raised: false, fill, outline: { color: stroke, widthPx: GEOJSON_STROKE_PX }, diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js index 4fa96767f..516457a5f 100644 --- a/src/globe/cesium/camera.js +++ b/src/globe/cesium/camera.js @@ -147,6 +147,7 @@ export function createCesiumCamera(viewer) { // The engine's own flight, so flightActive is exact even for flights that // completed synchronously (Cesium keeps no current flight for those). let flight = null; + let completing = false; function trackFlight(options) { const token = {}; flight = token; @@ -161,7 +162,9 @@ export function createCesiumCamera(viewer) { }, cancel: () => { end(); - options.cancel?.(); + // Cesium's completeFlight removes the tween (its cancel) before it + // lands the flight (its complete). + if (!completing) options.cancel?.(); }, }; } @@ -272,6 +275,14 @@ export function createCesiumCamera(viewer) { cesium.cancelFlight(); flight = null; }, + completeFlight() { + completing = true; + try { + cesium.completeFlight(); + } finally { + completing = false; + } + }, zoomIn(amount = cesium.defaultZoomAmount) { cesium.zoomIn(amount); }, diff --git a/src/globe/cesium/camera.test.mjs b/src/globe/cesium/camera.test.mjs index c8556a1fc..a5dd334d1 100644 --- a/src/globe/cesium/camera.test.mjs +++ b/src/globe/cesium/camera.test.mjs @@ -132,6 +132,33 @@ test('flightActive also reports a flight started straight on the Cesium camera', assert.equal(camera.flightActive(), false); }); +test('completeFlight lands at the destination and calls complete only', () => { + const { scene, engine } = createCameraViewer(); + // Cesium's own flight: a real tween collection under the real camera. + scene.tweens = new Cesium.TweenCollection(); + scene.screenSpaceCameraController = { enableInputs: true }; + const outcome = []; + const destination = geo.cartesian3.fromDegrees(86.925, 27.988, 40000); + engine.camera.completeFlight(); + engine.camera.flyTo({ + destination, + orientation: { heading: 0.5, pitch: -0.8, roll: 0 }, + duration: 8, + complete: () => outcome.push('complete'), + cancel: () => outcome.push('cancel'), + }); + assert.equal(engine.camera.flightActive(), true); + engine.camera.completeFlight(); + assert.deepEqual(outcome, ['complete']); + assert.equal(engine.camera.flightActive(), false); + assert.equal(scene.screenSpaceCameraController.enableInputs, true); + const pose = engine.camera.pose(); + assert.ok(geo.cartesian3.distance(pose.position, destination) < 0.01); + assert.ok(Math.abs(pose.heading - 0.5) < 1e-6); + assert.ok(Math.abs(pose.pitch + 0.8) < 1e-6); + engine.destroy(); +}); + test('setView with direction and up keeps the exact basis next to nadir', () => { const { camera, engine } = createCameraViewer(); const unit = (x, y, z) => diff --git a/src/globe/cesium/imagery.test.mjs b/src/globe/cesium/imagery.test.mjs index 68ce216e8..ab35924eb 100644 --- a/src/globe/cesium/imagery.test.mjs +++ b/src/globe/cesium/imagery.test.mjs @@ -623,3 +623,39 @@ test('a raised layer is a shell over its rectangle; a staged image swaps once it layer.destroy(); assert.equal(shells[1].destroyed, true); }); + +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'); +}); + +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/imageryStageCredit.test.mjs b/src/globe/cesium/imageryStageCredit.test.mjs deleted file mode 100644 index bc17c55b0..000000000 --- a/src/globe/cesium/imageryStageCredit.test.mjs +++ /dev/null @@ -1,162 +0,0 @@ -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 deleted file mode 100644 index 3c9d11fff..000000000 --- a/src/globe/cesium/imageryStageRequests.test.mjs +++ /dev/null @@ -1,162 +0,0 @@ -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/movers.js b/src/globe/cesium/movers.js index c5b6111e9..39a6a058b 100644 --- a/src/globe/cesium/movers.js +++ b/src/globe/cesium/movers.js @@ -231,8 +231,13 @@ function modelPart(options, store, { factories, requestRender, owners }) { const { model, pickable } = options; const models = []; const loading = []; - // Instances whose model failed: not tried again (MoverSet.failed). + // Instances whose model failed: not tried again (MoverSet.failed). An + // index holds the pickId it failed for: a layer moves another instance + // into a freed index, and that one loads its own model. const failed = []; + const failedFor = []; + const failedNow = (index) => + failed[index] === true && failedFor[index] === store.pickIds[index]; let destroyed = false; const place = (item, index) => { const { orientations } = store; @@ -292,6 +297,7 @@ function modelPart(options, store, { factories, requestRender, owners }) { .catch((error) => { if (destroyed) return; failed[index] = true; + failedFor[index] = store.pickIds[index]; console.warn(`[movers] ${options.id}: model failed`, error); }); }; @@ -306,13 +312,15 @@ function modelPart(options, store, { factories, requestRender, owners }) { return true; }, failed(index) { - if (index !== undefined) return failed[index] === true; - for (let at = 0; at < store.count; at++) if (failed[at]) return true; + if (index !== undefined) return failedNow(index); + for (let at = 0; at < store.count; at++) if (failedNow(at)) return true; return false; }, commit(range) { if (range) for (let index = range.from; index < range.to; index++) { + if (failed[index] && !failedNow(index)) + failed[index] = loading[index] = false; if (index < store.count) ensure(index); if (models[index]) place(models[index], index); } @@ -323,6 +331,7 @@ function modelPart(options, store, { factories, requestRender, owners }) { models.length = 0; loading.length = 0; failed.length = 0; + failedFor.length = 0; }, }; } diff --git a/src/globe/cesium/movers.test.mjs b/src/globe/cesium/movers.test.mjs index bdc8cf43d..640bb8ef8 100644 --- a/src/globe/cesium/movers.test.mjs +++ b/src/globe/cesium/movers.test.mjs @@ -442,6 +442,42 @@ test('a model that fails to load marks its instance failed, once', async (t) => assert.equal(set.failed(), false, 'a destroyed set reports nothing'); }); +// A layer removes an instance by moving its last one into the freed index +// (src/layers/shared/render/moverBank.js). +test('an instance moved into a failed index loads its own model', async (t) => { + t.mock.method(console, 'warn', () => {}); + let loads = 0; + const { movers } = setup({ + loadModel: async (options) => { + if (loads++ === 0) throw new Error('503'); + return fakeModel(options); + }, + }); + const set = movers.createSet({ + id: 'fleet', + kind: 'model', + capacity: 2, + model: { url: '/a.glb' }, + }); + set.set(0, { position: at(0, 0), pickId: 'a' }); + set.set(1, { position: at(1, 0), pickId: 'b' }); + set.setCount(2); + set.commit(); + await settle(); + assert.equal(set.failed(0), true); + assert.equal(set.failed(1), false); + // 'a' is removed: 'b' moves into index 0. + set.set(0, { position: at(1, 0), pickId: 'b' }); + set.setCount(1); + assert.equal(set.failed(0), false, "'b' does not take the failure of 'a'"); + assert.equal(set.failed(), false); + set.commit(); + await settle(); + assert.equal(loads, 3, 'index 0 loads again for its new instance'); + assert.equal(set.failed(0), false); + set.destroy(); +}); + 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/contract.js b/src/globe/contract.js index 4a73c5778..a50a6da35 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -106,6 +106,9 @@ * @property {() => Matrix4} transform A copy of the current camera transform. * @property {() => void} release lookAtTransform(IDENTITY). * @property {() => void} cancelFlight + * @property {() => void} completeFlight Cesium's completeFlight: a running + * flight lands at once (its destination and end transform) and calls + * `complete`, not `cancel`; without a flight it does nothing. * @property {(amount?: number) => void} zoomIn Moves `amount` metres along direction. * @property {(amount?: number) => void} zoomOut Moves `amount` metres against direction. * @property {() => ({ west: number, south: number, east: number, north: number } | undefined)} viewRectangle @@ -221,6 +224,7 @@ * 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. + * @property {(position: Cartesian3, exclude?: any[]) => (Cartesian3 | undefined)} clampToHeight * The position moved onto the rendered scene along the geodetic normal. * @property {boolean} clampToHeightSupported * @property {() => boolean} tilesLoaded @@ -472,6 +476,7 @@ * @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. + * Cesium loads per instance: an index given another pickId loads again. * @property {() => void} destroy * * @typedef {object} GlobeMovers @@ -980,6 +985,7 @@ const SHAPE = { transform: isFunction, release: isFunction, cancelFlight: isFunction, + completeFlight: isFunction, zoomIn: isFunction, zoomOut: isFunction, viewRectangle: isFunction, diff --git a/src/globe/navara/cameraChanged.test.mjs b/src/globe/navara/cameraChanged.test.mjs deleted file mode 100644 index 431791e5a..000000000 --- a/src/globe/navara/cameraChanged.test.mjs +++ /dev/null @@ -1,141 +0,0 @@ -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 e26414f35..c3ad4d4f1 100644 --- a/src/globe/navara/cameraModel.js +++ b/src/globe/navara/cameraModel.js @@ -206,27 +206,34 @@ export function createCameraModel({ options.complete?.(); return; } - const current = { options, resume: port.suspendCameraInputs?.() }; - flight = current; - // Navara moves the camera from here on; read it back every frame. - pushed = null; + const pose = navaraPose(target.position, target.direction, target.up); const flyOptions = {}; if (options.duration !== undefined) flyOptions.duration = options.duration * 1000; if (options.easing !== undefined) flyOptions.easing = NAVARA_EASINGS[options.easing]; - port - .fly(navaraPose(target.position, target.direction, target.up), flyOptions) - .then( - (arrived) => { - if (flight !== current || destroyed) return; - if (!arrived) return endFlight(current, 'cancel'); - endFlight(current, 'complete'); - land(options, target); - options.complete?.(); - }, - () => endFlight(current, 'cancel'), - ); + const current = { options, target, resume: port.suspendCameraInputs?.() }; + flight = current; + let pending; + try { + pending = port.fly(pose, flyOptions); + } catch (error) { + // As in Cesium, a flight that cannot start reports only the exception. + endFlight(current, 'fail'); + throw error; + } + // Navara moves the camera from here on; read it back every frame. + pushed = null; + pending.then( + (arrived) => { + if (flight !== current || destroyed) return; + if (!arrived) return endFlight(current, 'cancel'); + endFlight(current, 'complete'); + land(options, target); + options.complete?.(); + }, + () => endFlight(current, 'cancel'), + ); } /** Reads Navara's drawn camera; called once per frame before preRender. */ @@ -384,6 +391,16 @@ export function createCameraModel({ announce(); cancelCurrentFlight(); }, + completeFlight() { + const current = flight; + if (!current) return; + endFlight(current, 'complete'); + // Cesium lands it through setView, a command. + announce(); + port.stop(); + land(current.options, current.target); + current.options.complete?.(); + }, zoomIn(amount = DEFAULT_ZOOM_AMOUNT) { place({ ...state, diff --git a/src/globe/navara/cameraModel.test.mjs b/src/globe/navara/cameraModel.test.mjs index 6e6980575..50be031e7 100644 --- a/src/globe/navara/cameraModel.test.mjs +++ b/src/globe/navara/cameraModel.test.mjs @@ -273,6 +273,67 @@ test('easings map onto Navara presets and an omitted one stays Navara’s own', assert.equal(port.flights.at(-1).options.duration, undefined); }); +test('an invalid flight pose does not suspend camera inputs', () => { + const { camera, port } = setup(); + let suspensions = 0; + port.suspendCameraInputs = () => { + suspensions++; + return () => {}; + }; + assert.throws( + () => + camera.flyTo({ + destination: cartesian3.create(), + orientation: { + direction: cartesian3.create(0, 0, -1), + up: cartesian3.create(0, 1, 0), + }, + duration: 2, + }), + TypeError, + ); + assert.equal(suspensions, 0); + assert.equal(camera.flightActive(), false); + assert.equal(port.flights.length, 0); +}); + +test('a synchronous flight failure releases the camera inputs', async (context) => { + for (const method of ['flyTo', 'flyToSphere']) + await context.test(method, () => { + const { camera, port, model } = setup(); + const outcome = []; + let suspensions = 0; + port.suspendCameraInputs = () => { + suspensions++; + return () => suspensions--; + }; + const error = new Error('Flight could not start'); + port.fly = () => { + throw error; + }; + const destination = cartesian3.fromDegrees(10, 45, 5000); + const options = { + duration: 2, + complete: () => outcome.push('complete'), + cancel: () => outcome.push('cancel'), + }; + assert.throws( + () => + method === 'flyTo' + ? camera.flyTo({ destination, ...options }) + : camera.flyToSphere({ center: destination, radius: 100 }, options), + (thrown) => thrown === error, + ); + assert.equal(camera.flightActive(), false); + assert.equal(suspensions, 0); + assert.deepEqual(outcome, [], 'the exception alone reports it'); + camera.cancelFlight(); + model.destroy(); + assert.equal(suspensions, 0, 'the failed flight resumes only once'); + assert.deepEqual(outcome, []); + }); +}); + test('cancelFlight stops Navara in place and calls cancel, never complete', async () => { const { camera, port } = setup(); const outcome = []; @@ -290,6 +351,43 @@ test('cancelFlight stops Navara in place and calls cancel, never complete', asyn assert.deepEqual(outcome, ['cancel']); }); +test('completeFlight lands at the destination and calls complete, as Cesium', async () => { + const { camera, port } = setup(); + const outcome = []; + let suspensions = 0; + port.suspendCameraInputs = () => { + suspensions++; + return () => suspensions--; + }; + const destination = cartesian3.fromDegrees(86.925, 27.988, 40000); + camera.completeFlight(); + assert.equal(port.stops, 0, 'without a flight it does nothing'); + camera.flyTo({ + destination, + orientation: { heading: 0.5, pitch: -0.8, roll: 0 }, + duration: 8, + endTransform: matrix4.IDENTITY, + complete: () => outcome.push('complete'), + cancel: () => outcome.push('cancel'), + }); + // Navara is midway when the flight is completed. + const midway = cartesian3.fromDegrees(50, 15, 8e6); + port.drawn = { position: midway, ...orientationVectors(midway, 0, -1, 0) }; + camera.completeFlight(); + assert.deepEqual(outcome, ['complete']); + assert.equal(camera.flightActive(), false); + assert.equal(suspensions, 0, 'the camera inputs are back'); + assert.equal(port.stops, 1); + const pose = camera.pose(); + near(cartesian3.distance(pose.position, destination), 0, 1e-6, 'position'); + near(pose.heading, 0.5, 1e-9, 'heading'); + near(pose.pitch, -0.8, 1e-9, 'pitch'); + near(port.applied.at(-1).height, 40000, 1e-6, 'pushed to Navara'); + port.flights[0].settle(false); + await flush(); + assert.deepEqual(outcome, ['complete']); +}); + test('a newer flight cancels the previous one', async () => { const { camera, port } = setup(); const outcome = []; @@ -548,3 +646,42 @@ test('setView cancels a model flight before its promise settles', async () => { 'setView stays', ); }); + +// 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/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index 7faea5415..c95a5642a 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -739,3 +739,33 @@ test('ground picks use the visible surface and fall back to the globe', async () assert.equal(engine.picker.pick(at)?.id, 'roof'); engine.destroy(); }); + +// 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/flightInput.test.mjs b/src/globe/navara/flightInput.test.mjs deleted file mode 100644 index f6ba48b5e..000000000 --- a/src/globe/navara/flightInput.test.mjs +++ /dev/null @@ -1,186 +0,0 @@ -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/input.test.mjs b/src/globe/navara/input.test.mjs index e1f3a45ef..993ed22ce 100644 --- a/src/globe/navara/input.test.mjs +++ b/src/globe/navara/input.test.mjs @@ -313,3 +313,61 @@ test('custom windows keep down synchronous, moves coalesced and clicks on one GP ); destroy(); }); + +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/inputPinch.test.mjs b/src/globe/navara/inputPinch.test.mjs deleted file mode 100644 index e379fbe44..000000000 --- a/src/globe/navara/inputPinch.test.mjs +++ /dev/null @@ -1,104 +0,0 @@ -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/overlayPass.test.mjs b/src/globe/navara/overlayPass.test.mjs index 5d0fd4b4d..0f1835b7d 100644 --- a/src/globe/navara/overlayPass.test.mjs +++ b/src/globe/navara/overlayPass.test.mjs @@ -238,3 +238,41 @@ test('ordinary overlays keep the direct pass without a display copy or target', assert.equal(h.renderer.autoClear, true); h.pass.dispose(); }); + +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, + ); + Object.assign(h.overlay.uniforms, { + uGevSonar: { value: [0, 1, 0.84, 1] }, + uGevSonarSector: { value: 24 }, + uGevSonarEnabled: { value: 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/overlaySonar.test.mjs b/src/globe/navara/overlaySonar.test.mjs deleted file mode 100644 index 1efd84317..000000000 --- a/src/globe/navara/overlaySonar.test.mjs +++ /dev/null @@ -1,110 +0,0 @@ -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/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 6599a8b16..73cdfe9e0 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -1490,6 +1490,40 @@ export function createContractSuite( ensure(outcome.join() === 'cancel', `flight ended with ${outcome}`); }, ], + [ + 'camera.completeFlight: lands a running flight at once and calls complete', + async () => { + await nadirView(); + camera.completeFlight(); + const outcome = []; + const destination = cartesian3.fromDegrees(100, 0, 5e6); + camera.flyTo({ + destination, + orientation: { heading: 0.5, pitch: -0.8, roll: 0 }, + duration: 5, + complete: () => outcome.push('complete'), + cancel: () => outcome.push('cancel'), + }); + await settle(2); + camera.completeFlight(); + ensure( + outcome.join() === 'complete', + `completeFlight returned with [${outcome}] instead of [complete]`, + ); + ensure(!camera.flightActive(), 'the flight is still active'); + const pose = camera.pose(); + near( + cartesian3.distance(pose.position, destination), + 0, + 0.01, + 'position error (m)', + ); + near(angleBetween(pose.heading, 0.5), 0, 1e-6, 'heading error'); + near(pose.pitch, -0.8, 1e-6, 'pitch'); + await settle(3); + ensure(outcome.join() === 'complete', `flight ended with ${outcome}`); + }, + ], [ 'camera.setView: cancels an active flight and retains its destination', async () => { diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 76432aea7..fb8180230 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -590,6 +590,14 @@ export function createFakeEngine(options = {}) { command('camera.cancelFlight', []); cancelCurrentFlight(); }, + completeFlight() { + const current = flight; + if (!current) return record('camera.completeFlight', []); + flight = null; + command('camera.completeFlight', []); + land(current, current.target); + current.complete?.(); + }, zoomIn(amount = DEFAULT_ZOOM_AMOUNT) { record('camera.zoomIn', [amount]); state.position = sum(state.position, scaled(state.direction, amount)); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index d06b1e649..fb7e91a6c 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3478,5 +3478,7 @@ "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}" + "Kept in the link: {layers}": "محفوظة في الرابط: {layers}", + "Could not start {engine}: {message}": "تعذّر تشغيل {engine}: {message}", + "Choose an engine to try again.": "اختر محركًا لإعادة المحاولة." } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 873a808cb..aa9479233 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3178,5 +3178,7 @@ "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}" + "Kept in the link: {layers}": "Im Link beibehalten: {layers}", + "Could not start {engine}: {message}": "{engine} konnte nicht starten: {message}", + "Choose an engine to try again.": "Engine wählen, um es erneut zu versuchen." } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 239cce022..6f77f7c91 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3253,5 +3253,7 @@ "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}" + "Kept in the link: {layers}": "Se conservan en el enlace: {layers}", + "Could not start {engine}: {message}": "No se pudo iniciar {engine}: {message}", + "Choose an engine to try again.": "Elige un motor para intentarlo de nuevo." } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 36256617f..817a73c0b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3253,5 +3253,7 @@ "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}" + "Kept in the link: {layers}": "Conservées dans le lien : {layers}", + "Could not start {engine}: {message}": "Impossible de démarrer {engine} : {message}", + "Choose an engine to try again.": "Choisissez un moteur pour réessayer." } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index c986bbc44..e537771d4 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3253,5 +3253,7 @@ "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}" + "Kept in the link: {layers}": "Conservati nel link: {layers}", + "Could not start {engine}: {message}": "Impossibile avviare {engine}: {message}", + "Choose an engine to try again.": "Scegli un motore per riprovare." } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 4657ac0ef..4b69165d0 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3103,5 +3103,7 @@ "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}" + "Kept in the link: {layers}": "リンクに保持:{layers}", + "Could not start {engine}: {message}": "{engine} を起動できませんでした:{message}", + "Choose an engine to try again.": "エンジンを選んでもう一度お試しください。" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 43bf90e36..abc065990 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3253,5 +3253,7 @@ "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}" + "Kept in the link: {layers}": "Mantidas no link: {layers}", + "Could not start {engine}: {message}": "Não foi possível iniciar {engine}: {message}", + "Choose an engine to try again.": "Escolha um motor para tentar novamente." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index ea48ce635..7837c5501 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3328,5 +3328,7 @@ "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}" + "Kept in the link: {layers}": "Сохранены в ссылке: {layers}", + "Could not start {engine}: {message}": "Не удалось запустить {engine}: {message}", + "Choose an engine to try again.": "Выберите движок, чтобы попробовать снова." } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 7dde7ec4b..9e40042f9 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3328,5 +3328,7 @@ "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}" + "Kept in the link: {layers}": "Збережено в посиланні: {layers}", + "Could not start {engine}: {message}": "Не вдалося запустити {engine}: {message}", + "Choose an engine to try again.": "Виберіть рушій, щоб спробувати знову." } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index b6f49a375..0481eed3d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3103,5 +3103,7 @@ "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}" + "Kept in the link: {layers}": "已保留在链接中:{layers}", + "Could not start {engine}: {message}": "{engine} 启动失败:{message}", + "Choose an engine to try again.": "选择一个引擎重试。" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index a6e70fa6c..d3e31ff96 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3103,5 +3103,7 @@ "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}" + "Kept in the link: {layers}": "已保留在鏈接中:{layers}", + "Could not start {engine}: {message}": "{engine} 啟動失敗:{message}", + "Choose an engine to try again.": "選擇一個引擎重試。" } diff --git a/src/layers/aircraft/render/cockpitContactDot.test.mjs b/src/layers/aircraft/render/cockpitContactDot.test.mjs index fbb46da88..19836bd42 100644 --- a/src/layers/aircraft/render/cockpitContactDot.test.mjs +++ b/src/layers/aircraft/render/cockpitContactDot.test.mjs @@ -66,3 +66,51 @@ test('a cockpit contact draws the contact dot, not the aircraft silhouette', asy display.destroy(); engine.destroy(); }); + +// The dot bank, rebuilt for capacity after its first contact turned back +// into a silhouette, keeps the dot atlas. +test('a regrown cockpit-dot bank keeps the dot atlas', async () => { + const engine = createFakeEngine(); + const display = createAircraftDisplay(engine, { + layerId: 'flights', + order: 8, + headingOffsetDeg: 180, + drawAtlas, + }); + await display.whenReady(); + display.fleet.attach(); + const add = (i) => + display.fleet.add({ + position: geo.cartesian3.fromDegrees(-97.7 + i * 0.001, 30.2, 3000), + image: aircraftIcon('airliner'), + width: 20, + height: 20, + scale: 1, + rotation: 0, + color: { red: 1, green: 1, blue: 1, alpha: 1 }, + id: `c${i}`, + show: true, + }); + const first = add(0); + await settle(); + first.image = cockpitContactDotImage(); // far: the dot bank is created from it + const dotBank = first.gevBank; + first.image = aircraftIcon('airliner'); // near again: back to the silhouette + const others = []; + for (let i = 1; i <= 1100; i++) { + const s = add(i); + s.image = cockpitContactDotImage(); + others.push(s); + } + engine.fake.frames(2); + await settle(); + assert.equal(others[0].gevBank, dotBank); + const url = engine.fake.moverSet(dotBank.set.id).options.sprite.atlas.url; + assert.equal( + url, + cockpitContactDotImage(), + 'the regrown dot bank draws another image', + ); + display.destroy(); + engine.destroy(); +}); diff --git a/src/layers/aircraft/render/display.js b/src/layers/aircraft/render/display.js index bf03bd4da..8c3c644fd 100644 --- a/src/layers/aircraft/render/display.js +++ b/src/layers/aircraft/render/display.js @@ -29,6 +29,9 @@ import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; import { createMoverBank, writeFields } from '../../shared/render/moverBank.js'; const BASE_SIZE_PX = 20; +// A failed glTF is fetched again no sooner than this (the layers' tracked +// model backoff), not on the next fleet tick. +const MODEL_RETRY_BACKOFF_MS = 1500; /** * The model frame → the glTF's own axes (the movers' orientation): Cesium's @@ -194,7 +197,10 @@ export function createAircraftDisplay( pickable: true, sprite: { atlas: dot - ? { url: sprite.image, cells: { 'cockpit-dot': [0, 0, 1, 1] } } + ? { + url: cockpitContactDotImage(), + cells: { 'cockpit-dot': [0, 0, 1, 1] }, + } : atlas, sizePx: BASE_SIZE_PX, scaleByDistance: scalar @@ -454,6 +460,7 @@ export function createAircraftDisplay( // --- models ---------------------------------------------------------------- let modelsVisible = true; const modelBanks = new Map(); + const modelRetryAt = new Map(); const scratchMatrix3 = geo.matrix3.create(); const scratchCorrected = geo.matrix4.create(); const scratchQuaternion = geo.quaternion.create(); @@ -596,13 +603,15 @@ export function createAircraftDisplay( } } - // A set does not load a failed glTF again: the tracked model's retry - // loads it in a new one. + // A failed glTF needs a fresh set, created only when a retry is admitted. const dropModel = (model) => { const bank = model?.gevBank; - const retry = model?.tracked && model.failed; + const retry = model?.failed; bank?.remove(model); - if (retry) bank.rebuild(); + if (retry) { + modelRetryAt.set(model.spec.url, Date.now() + MODEL_RETRY_BACKOFF_MS); + bank.rebuild(); + } }; const models = { @@ -611,9 +620,11 @@ export function createAircraftDisplay( modelsVisible = visible; for (const bank of modelBanks.values()) bank.setVisible(visible); }, - loadFleet: (spec) => Promise.resolve(new ModelHandle(spec)), - loadTracked: (spec) => - Promise.resolve(Object.assign(new ModelHandle(spec), { tracked: true })), + loadFleet: (spec) => + Date.now() < (modelRetryAt.get(spec.url) ?? 0) + ? Promise.reject(new Error('model retry backoff')) + : Promise.resolve(new ModelHandle(spec)), + loadTracked: (spec) => Promise.resolve(new ModelHandle(spec)), add(model) { modelBankFor(model).add(model); }, diff --git a/src/layers/aircraft/trackedModelFailure.test.mjs b/src/layers/aircraft/trackedModelFailure.test.mjs index e1c1ffd7a..1038f0b94 100644 --- a/src/layers/aircraft/trackedModelFailure.test.mjs +++ b/src/layers/aircraft/trackedModelFailure.test.mjs @@ -1,7 +1,8 @@ -// 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. +// An aircraft 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 tracked contact gives up after its +// failure budget. A failed fleet model gives its slot back at once, and a +// later aircraft of its class loads the glTF again after the backoff. import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; @@ -23,6 +24,11 @@ import militaryFlightsLayer, { } from '../../data/militaryFlights.js'; import { clearTr3bRegistry } from '../../data/tr3bRegistry.js'; import { recordingAircraftRenderer } from '../../testSupport/aircraftRecordingRenderer.mjs'; +import { fleetTickFixture } from '../../testSupport/fleetTickFixture.mjs'; +import { createFlightState as civilState } from '../flights/state.js'; +import { createRendering as civilRendering } from '../flights/rendering.js'; +import { createFlightState as militaryState } from '../military/state.js'; +import { createRendering as militaryRendering } from '../military/rendering.js'; globalThis.window ??= { dispatchEvent: () => true, @@ -58,6 +64,11 @@ test('a failed tracked model loads again in a new set once it is removed', async failing.add(failedSet); assert.equal(first.failed, true, 'the engine reports the failed load'); display.models.remove(first); + assert.equal( + engine.movers.list().filter((set) => set.kind === 'model').length, + 0, + 'removal does not start another load', + ); const retry = await display.models.loadTracked(spec); display.models.add(retry); @@ -181,3 +192,93 @@ for (const fixture of LAYERS) { } }); } + +test('a failed fleet model does not condemn later aircraft of its class', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 1000 }); + 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 = (id) => ({ + url: 'airliner.glb', + minimumPixelSize: 64, + scale: 1, + id, + }); + const first = await display.models.loadFleet(spec('a1')); + display.models.add(first); + failing.add(first.gevBank.set.id); // a 5xx or a network blip on the GLB + assert.equal(first.failed, true); + display.models.remove(first); + await assert.rejects(display.models.loadFleet(spec('a2')), /retry/); + t.mock.timers.tick(1500); + const next = await display.models.loadFleet(spec('a2')); + display.models.add(next); + assert.equal( + next.failed, + false, + 'the next airliner joined the failed set and never draws its model', + ); +}); + +for (const [name, createFlightState, createRendering] of [ + ['flights', civilState, civilRendering], + ['military', militaryState, militaryRendering], +]) + for (const inView of [true, false]) + test(`${name}: the fleet tick frees a failed model's slot (${inView ? 'in view' : 'beyond the horizon'})`, () => { + const fleet = fleetTickFixture({ createFlightState, createRendering }); + if (inView) + fleet.camera.positionWC = Cesium.Cartesian3.fromDegrees(1, 0, 100); + fleet.flightState._models3dEnabled = true; + const model = { failed: true, ready: false, show: false }; + fleet.flightState._models.set(fleet.id, model); + fleet.tick(); + assert.equal(fleet.flightState._models.has(fleet.id), false); + assert.deepEqual(fleet.removed, [model]); + }); + +test('removing failures from a shared bank does not reload until the backoff ends', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 1000 }); + const engine = createFakeEngine(); + t.after(() => engine.destroy()); + let builds = 0; + const createSet = engine.movers.createSet; + engine.movers.createSet = (options) => { + if (options.kind === 'model') builds++; + return createSet(options); + }; + const display = createAircraftDisplay(engine, { + layerId: 'flights', + drawAtlas, + }); + t.after(() => display.destroy()); + const spec = { url: 'airliner.glb', minimumPixelSize: 64 }; + const first = await display.models.loadFleet({ ...spec, id: 'a' }); + const second = await display.models.loadFleet({ ...spec, id: 'b' }); + display.models.add(first); + display.models.add(second); + const failedId = first.gevBank.set.id; + engine.fake.script('moverFailed', (id) => id === failedId); + display.models.remove(first); + assert.equal(second.failed, true); + assert.equal(builds, 1, 'another handle in the bank does not trigger a load'); + display.models.remove(second); + assert.equal(builds, 1); + t.mock.timers.tick(1499); + await assert.rejects(display.models.loadFleet(spec), /retry/); + t.mock.timers.tick(1); + const retry = await display.models.loadFleet(spec); + assert.equal(builds, 1, 'a load starts only when the handle is admitted'); + display.models.add(retry); + assert.equal(builds, 2); + assert.equal(retry.failed, false); +}); diff --git a/src/layers/cctv/viewFovy.test.mjs b/src/layers/cctv/viewFovy.test.mjs index 5c46a4898..5e48b5fb0 100644 --- a/src/layers/cctv/viewFovy.test.mjs +++ b/src/layers/cctv/viewFovy.test.mjs @@ -31,7 +31,6 @@ test('icon thinning uses the camera vertical FOV, not a pi/3 fallback', () => { 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 } }, diff --git a/src/layers/flights/hiddenContactRotation.test.mjs b/src/layers/flights/hiddenContactRotation.test.mjs index 76e529092..74a7089e6 100644 --- a/src/layers/flights/hiddenContactRotation.test.mjs +++ b/src/layers/flights/hiddenContactRotation.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { createFlightState } from './state.js'; import { createRendering } from './rendering.js'; -import { horizonVisible } from '../../globe/cameraMath.js'; +import { fleetTickFixture } from '../../testSupport/fleetTickFixture.mjs'; import { FLEET_DR_INTERVAL_MS, HIDDEN_CONTACT_DR_INTERVAL_MS, @@ -45,95 +45,14 @@ test('a worldwide fleet is spread evenly enough that no tick carries a burst', ( for (const count of perSlot) assert.ok(count < fair * 1.5, `${count} > 1.5×`); }); -/** Real fleet tick and horizon culling, with motion and presentation injected. */ -function fleetFixture() { - let now = 1000; - let reckonings = 0; - const services = { - groundSnap: { createGroundSnap: () => ({ clear() {} }) }, - militaryRegistry: { isMilitaryIcao: () => false }, - aircraftPresentation: { isTr3b: () => false }, - camera: {}, - focus: { - focusNowMs: () => now, - getFocusTarget: () => null, - nearFarScalarValueAtDistance: () => 1, - advanceProjectedSpriteFocus: () => ({ factor: 1 }), - }, - recession: { - applyAircraftBillboardTreatment: () => ({ - factors: { scale: 1 }, - alpha: 1, - }), - }, - }; - const flightState = createFlightState({ services }); - const camera = { - positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100), - positionCartographic: { height: 100 }, - heading: 0, - pitch: 0, - roll: 0, - frustum: { - computeCullingVolume: () => ({ - computeVisibility: () => Cesium.Intersect.INSIDE, - }), - }, - }; - const id = 'abc123'; - // 111 km away at ground level is beyond a 100 m camera's horizon, but - // inside the model keep radius. No WebGL scene is needed for the culler. - const billboard = { - show: false, - position: Cesium.Cartesian3.fromDegrees(1, 0, 0), - }; - // The flights renderer's fleet and view, as the Cesium renderer gives them. - flightState._renderer = { - fleet: { visible: () => true }, - view: { - camera, - scene: {}, - horizon: () => ({ - isPointVisible: (position) => - horizonVisible(camera.positionWC, position), - }), - frustumTester: () => () => true, - }, - }; - flightState._billboards.set(id, billboard); - flightState.records.data.set(id, {}); - flightState._models3dEnabled = false; - // Far cockpit contacts avoid glyph rasterization, keeping the fixture DOM-free. - flightState._cockpitContactMode = true; - const parts = { - tracking: { _refreshCockpitNearContacts() {} }, - motion: { - _deadReckon() { - reckonings++; - return billboard.position; - }, - _floorGroundedDisplayPosition(id, info, position) { - return position; - }, - }, - }; - const rendering = createRendering({ flightState, services, parts }); - return { - flightState, - camera, - billboard, - id, - reckonings: () => reckonings, - tick() { - now += FLEET_DR_INTERVAL_MS; - rendering._fleetTick(); - }, - offTurn() { - flightState._fleetTickSeq = - (hiddenContactDrPhase(id) + 1) % HIDDEN_CONTACT_DR_TICKS; - }, - }; -} +// The real fleet tick and horizon culling, with motion and presentation injected. +const fleetFixture = () => + fleetTickFixture({ createFlightState, createRendering }); +// The next tick is not this contact's turn in the hidden rotation. +const offTurn = (f) => { + f.flightState._fleetTickSeq = + (hiddenContactDrPhase(f.id) + 1) % HIDDEN_CONTACT_DR_TICKS; +}; test('the fleet tick dead-reckons a hidden contact once per complete rotation', () => { const f = fleetFixture(); @@ -144,11 +63,11 @@ test('the fleet tick dead-reckons a hidden contact once per complete rotation', test('a camera reveal bypasses the hidden rotation on the same fleet tick', () => { const f = fleetFixture(); - f.offTurn(); + offTurn(f); f.tick(); assert.equal(f.reckonings(), 0); f.camera.positionWC = Cesium.Cartesian3.fromDegrees(1, 0, 100); - f.offTurn(); + offTurn(f); f.tick(); assert.equal(f.reckonings(), 1); assert.equal(f.billboard.show, true); @@ -159,7 +78,7 @@ test('a hidden contact with a retained fleet model is never rotation-skipped', ( f.flightState._models3dEnabled = true; const model = { ready: true, show: true }; f.flightState._models.set(f.id, model); - f.offTurn(); + offTurn(f); f.tick(); assert.equal(f.reckonings(), 1); assert.equal( diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index fb2dc7c51..9865f29aa 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -825,6 +825,10 @@ export function createRendering({ const occluder = view.horizon(); const focusTarget = getFocusTarget(); + // A model the engine failed to load gives its slot back, in view or not; + // the renderer loads its class again after a backoff. + for (const [icao24, model] of flightState._models) + if (model.failed) _releaseModel(icao24); // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. // Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume. const useModels = _modelRegimeActive(); diff --git a/src/layers/infrastructure/render/inViewContext.test.mjs b/src/layers/infrastructure/render/inViewContext.test.mjs index 1a43426a8..1730cd5db 100644 --- a/src/layers/infrastructure/render/inViewContext.test.mjs +++ b/src/layers/infrastructure/render/inViewContext.test.mjs @@ -1,7 +1,4 @@ -// 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. +// In-view entity context includes infrastructure drawn on screen. import test from 'node:test'; import assert from 'node:assert/strict'; import * as geo from '../../../geo/index.js'; diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js index 94d43d6c9..3d649fc01 100644 --- a/src/layers/localAdsb/models.js +++ b/src/layers/localAdsb/models.js @@ -208,6 +208,10 @@ export function createLocalAdsbModels({ for (const [id, marker] of markers) { const model = models.get(id); marker.modelOwnsVisual = false; + if (model?.failed) { + release(id); + continue; + } if (!model || !marker.position) continue; const position = placement(id, marker); if (!position || !occluder.isPointVisible(marker.position)) { diff --git a/src/layers/localAdsb/models.test.mjs b/src/layers/localAdsb/models.test.mjs index 31734e54e..e5b2f5516 100644 --- a/src/layers/localAdsb/models.test.mjs +++ b/src/layers/localAdsb/models.test.mjs @@ -48,3 +48,45 @@ test('ground snapping excludes the aircraft by its engine pick id', async () => assert.deepEqual(exclusions, [['local-adsb:abc123']]); models.destroy(); }); + +test('a failed model frees its slot and leaves the marker visible', async () => { + const position = geo.cartesian3.fromDegrees(0, 0, 100); + const removed = []; + let loads = 0; + const renderer = { + models: { + loadFleet: async () => ({ failed: loads++ === 0, ready: loads > 1 }), + add() {}, + remove: (model) => removed.push(model), + destroy() {}, + setPose() {}, + }, + view: { + camera: { positionWC: position, positionCartographic: { height: 100 } }, + frustumTester: () => () => true, + horizon: () => ({ isPointVisible: () => true }), + }, + }; + const models = createLocalAdsbModels({ + renderer, + color: {}, + resolveAsset: (url) => url, + }); + const marker = { position, klass: 'airliner' }; + const markers = new Map([['local-adsb:abc123', marker]]); + try { + models.frame(markers, { models3d: true }, 1000); + await new Promise(setImmediate); + models.frame(markers, { models3d: true }, 1100); + assert.equal(models.size, 0); + assert.equal(removed.length, 1); + assert.equal(marker.modelOwnsVisual, false); + models.frame(markers, { models3d: true }, 2700); + await new Promise(setImmediate); + models.frame(markers, { models3d: true }, 2800); + assert.equal(models.size, 1); + assert.equal(marker.modelOwnsVisual, true); + } finally { + models.destroy(); + } +}); diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 81a732947..08635485e 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -805,6 +805,10 @@ export function createRendering({ const occluder = view.horizon(); const focusTarget = getFocusTarget(); + // A model the engine failed to load gives its slot back, in view or not; + // the renderer loads its class again after a backoff. + for (const [icao24, model] of flightState._models) + if (model.failed) _releaseModel(icao24); // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. // Drop all models the moment we leave it (toggled off / zoomed out) so billboards resume. const useModels = _modelRegimeActive(); diff --git a/src/layers/osmPlaces/render/display.js b/src/layers/osmPlaces/render/display.js index 73c90c268..c9b2c60a7 100644 --- a/src/layers/osmPlaces/render/display.js +++ b/src/layers/osmPlaces/render/display.js @@ -6,17 +6,13 @@ import * as geo from '../../../geo/index.js'; import { rgbaFromCss } from '../../shared/color.js'; +import { createSurfacePoints } from '../../shared/render/surfacePoints.js'; import { OSM_PLACES_LAYER_ID, OSM_PLACE_COLOR } from '../model.js'; // Above the sampled surface, so a point does not sink into it. const HEIGHT_OFFSET_M = 2; // The voice search's result cap; a larger set grows the batch. 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; @@ -32,11 +28,14 @@ export function createOsmPlacesDisplay(engine) { let set = null, capacity = 0, visible = false, - records = [], - destroyed = false, - retry = null, - revision = 0; - const heights = new Map(); + points = [], + destroyed = false; + const surface = createSurfacePoints(engine, { + groups: () => [{ set, points: visible ? points : [] }], + request: () => engine.render.request(OSM_PLACES_LAYER_ID), + heightOffset: HEIGHT_OFFSET_M, + estimateGround: true, + }); function ensure(count) { if (set && count <= capacity) return; set?.destroy(); @@ -53,98 +52,20 @@ export function createOsmPlacesDisplay(engine) { } function draw() { if (destroyed) return; - clearTimeout(retry); - retry = null; - const shown = visible ? records : []; + const shown = visible ? points : []; ensure(shown.length); - const sampling = engine.surface.supported; - let sampleTime = 0; - const now = Date.now(); - const size = engine.host.size(); - let samples = 0; - let delay = Infinity; - shown.forEach((record, index) => { - 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], - ); - 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)); - } + shown.forEach((point, index) => { set.set(index, { - position, + position: point.position, color, show: true, - pickId: osmPlacePickId(record), + pickId: osmPlacePickId(point.record), }); }); set.setCount(shown.length); set.commit(); - if (Number.isFinite(delay)) retry = setTimeout(draw, delay); + surface.place(); } - 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) { if (visible === Boolean(show)) return; @@ -152,21 +73,24 @@ export function createOsmPlacesDisplay(engine) { draw(); }, replace(next) { - records = next.slice(); - heights.clear(); - revision++; + points = next.map((record) => ({ + record, + cartographic: geo.cartographic.fromDegrees(record.lon, record.lat), + position: geo.cartesian3.fromDegrees( + record.lon, + record.lat, + HEIGHT_OFFSET_M, + ), + })); draw(); }, destroy() { if (destroyed) return; destroyed = true; - clearTimeout(retry); - removeMoveEnd(); - removeSurface(); - heights.clear(); + surface.destroy(); set?.destroy(); set = null; - records = []; + points = []; engine.render.request('osm-places-destroy'); }, }; diff --git a/src/layers/osmPlaces/render/display.test.mjs b/src/layers/osmPlaces/render/display.test.mjs index 2c6a11207..2ac40ecdc 100644 --- a/src/layers/osmPlaces/render/display.test.mjs +++ b/src/layers/osmPlaces/render/display.test.mjs @@ -25,7 +25,7 @@ function fakeHarness() { engine, /** The surface under every place answers `height` (undefined: none). */ surface(height) { - engine.fake.script('sampleHeight', height); + engine.fake.script('terrainHeight', height); }, destroy: () => engine.destroy(), }; @@ -79,7 +79,6 @@ 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', 'Date'] }); const h = makeHarness(); const display = createOsmPlacesDisplay(h.engine); const position = () => @@ -89,14 +88,17 @@ for (const makeHarness of HARNESSES) { })(); try { h.surface(undefined); + h.engine.fake.script('tilesLoaded', false); display.setVisible(true); display.replace(places); assert.ok(Math.abs(heightOf(position()) - 2) < 1e-6, 'no surface yet'); h.surface(1340); - t.mock.timers.tick(500); + h.engine.fake.frames(1); + h.engine.fake.script('tilesLoaded', true); + h.engine.fake.frames(1); assert.ok( Math.abs(heightOf(position()) - 1342) < 1e-6, - 'asked again shortly after', + 'asked again once the terrain loads', ); h.surface(1360); h.engine.camera.moveEnd.raiseEvent(); @@ -151,7 +153,7 @@ test('places refine loaded heights after tiles settle', (t) => { engine.projector.horizonVisible = () => true; let height = 50; let loaded = false; - engine.fake.script('sampleHeight', () => height); + engine.fake.script('terrainHeight', () => height); engine.fake.script('tilesLoaded', () => loaded); const display = createOsmPlacesDisplay(engine); t.after(() => display.destroy()); @@ -169,11 +171,12 @@ test('places refine loaded heights after tiles settle', (t) => { ); }); -test('large searches only sample visible places within a per-pass budget', (t) => { +test('large searches only sample visible places within a per-pass budget', async (t) => { const engine = createFakeEngine(); t.after(() => engine.destroy()); engine.projector.worldToWindow = () => ({ x: 100, y: 100 }); engine.projector.horizonVisible = () => true; + await engine.maps.setPhotorealTiles({ id: 'photoreal' }); let samples = 0; engine.fake.script('sampleHeight', () => { samples++; @@ -191,3 +194,56 @@ test('large searches only sample visible places within a per-pass budget', (t) = engine.camera.moveEnd.raiseEvent(); assert.equal(samples, 0, 'off-screen places are not sampled'); }); + +// A place mid-view whose unmeasured point (2 m above the ellipsoid) +// projects off screen is still measured. +// Under 3D tiles Cesium hides the globe, and with it the terrain. +for (const [tiles, terrain] of [ + [false, 1300], + [true, 1300], + [true, undefined], +]) + test(`a visible place on high ground is measured before culling (tiles: ${tiles}, terrain: ${terrain})`, async () => { + const engine = createFakeEngine({ + longitude: geo.math.toRadians(85.31), + latitude: geo.math.toRadians(27.7), + altitude: 1500, + }); + // The ground under the place is at 1300 m; the camera, 200 m above it, + // sees it mid-frame, while the point 1300 m lower projects below the frame. + if (tiles) await engine.maps.setPhotorealTiles({ id: 'photoreal' }); + engine.fake.script('terrainHeight', terrain); + let samples = 0; + engine.fake.script('sampleHeight', () => (samples++, 1300)); + engine.projector.horizonVisible = () => true; + engine.projector.worldToWindow = (position) => + geo.cartographic.fromCartesian(position).height > 1000 + ? { x: 600, y: 450 } + : { x: 600, y: 2400 }; + const display = createOsmPlacesDisplay(engine); + try { + display.replace([ + { id: 'node/1', name: 'Town hall', lat: 27.7, lon: 85.31 }, + ]); + display.setVisible(true); + engine.fake.emit('camera.moveEnd'); + assert.equal( + samples > 0, + tiles, + 'pick samples are only needed under 3D tiles', + ); + const { positions } = engine.fake.moverSet('osm-places').store; + const height = geo.cartographic.fromCartesian({ + x: positions[0], + y: positions[1], + z: positions[2], + }).height; + assert.ok( + Math.abs(height - 1302) < 0.01, + 'the place stands above the visible ground', + ); + } finally { + display.destroy(); + engine.destroy(); + } + }); diff --git a/src/layers/shared/render/moverBank.js b/src/layers/shared/render/moverBank.js index e279511f8..f23968b6a 100644 --- a/src/layers/shared/render/moverBank.js +++ b/src/layers/shared/render/moverBank.js @@ -35,11 +35,13 @@ export function createMoverBank(engine, { options, write, onCommit }) { let capacity = 0; let visible = true; let dirty = false; + let rebuildPending = false; const build = (wanted) => { const next = engine.movers.createSet(options(wanted, ++generation)); set?.destroy(); set = next; capacity = wanted; + rebuildPending = false; handles.forEach((handle, index) => write(handle, set, index)); set.setCount(visible ? handles.length : 0); }; @@ -55,8 +57,14 @@ export function createMoverBank(engine, { options, write, onCommit }) { handle.gevBank = bank; handle.gevIndex = handles.length; handles.push(handle); - if (!set || handles.length > capacity) - build(Math.max(FIRST_CAPACITY, capacity * 2, handles.length)); + if (!set || rebuildPending || handles.length > capacity) + build( + Math.max( + FIRST_CAPACITY, + handles.length > capacity ? capacity * 2 : capacity, + handles.length, + ), + ); else { write(handle, set, handle.gevIndex); if (visible) set.setCount(handles.length); @@ -75,6 +83,7 @@ export function createMoverBank(engine, { options, write, onCommit }) { handle.gevBank = null; handle.gevIndex = -1; set?.setCount(visible ? handles.length : 0); + if (!handles.length && rebuildPending) bank.rebuild(); bank.touch(); }, /** Writes `fields` of one handle (when it is in this bank). */ @@ -89,9 +98,13 @@ export function createMoverBank(engine, { options, write, onCommit }) { bank.touch(); }, visible: () => visible, - /** The same handles in a new set (one that loads its assets again). */ + /** Reload assets on the next add, after the caller's retry backoff. */ rebuild() { - if (set) build(capacity); + rebuildPending = true; + if (!handles.length) { + set?.destroy(); + set = null; + } }, touch() { if (!dirty) { diff --git a/src/layers/shared/render/surfacePoints.js b/src/layers/shared/render/surfacePoints.js index ed44db022..867bdf711 100644 --- a/src/layers/shared/render/surfacePoints.js +++ b/src/layers/shared/render/surfacePoints.js @@ -31,14 +31,22 @@ const plausible = (height) => * placement replaces (keeping the height in `surfaceHeight`); one with * `show === false` is skipped. * @param {() => void} options.request Asks for a frame after a placement. + * @param {number} [options.heightOffset=0] Metres above the surface. + * @param {boolean} [options.estimateGround=false] Screen-test a point not + * measured yet on the loaded terrain (under 3D tiles without it, at the + * ground under the camera) rather than where it stands now. * @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 }) { +export function createSurfacePoints( + engine, + { groups, request, heightOffset = 0, estimateGround = false }, +) { let revision = 0; let timer = null; let destroyed = false; let tilesLoaded = engine.surface.tilesLoaded(); + let photoreal = engine.maps?.photorealActive?.() === true; function onScreen(position, size) { const screen = engine.projector.worldToWindow(position); @@ -53,6 +61,16 @@ export function createSurfacePoints(engine, { groups, request }) { ); } + // Under 3D tiles Cesium hides the globe, which then loads no terrain: the + // ground under the camera stands in for it in the screen test. + function groundUnderCamera(exclude) { + const height = engine.surface.sampleHeight( + geo.cartographic.fromCartesian(engine.camera.livePosition), + exclude, + ); + return plausible(height) ? height : Number.NaN; + } + // Points off screen are passed over until the next move or tile load. function place() { clearTimeout(timer); @@ -63,6 +81,7 @@ export function createSurfacePoints(engine, { groups, request }) { const started = performance.now(); let samples = 0; let moved = false; + let cameraGround; for (const { set, points, exclude = [set] } of groups()) { if (!set) continue; let changed = false; @@ -79,20 +98,40 @@ export function createSurfacePoints(engine, { groups, request }) { return; } point.surfaceRevision = revision; - if (point.show === false || !onScreen(point.position, size)) continue; + if (point.show === false) continue; + const estimate = estimateGround && point.surfaceHeight === undefined; + const terrain = estimate + ? engine.surface.terrainHeight(point.cartographic) + : undefined; + let ground = terrain; + if (estimate && tiles && !plausible(ground)) { + if (cameraGround === undefined) { + samples++; + cameraGround = groundUnderCamera(exclude); + } + ground = cameraGround; + } + const position = plausible(ground) + ? geo.cartesian3.fromRadians( + point.cartographic.longitude, + point.cartographic.latitude, + ground + heightOffset, + ) + : point.position; + if (!onScreen(position, size)) continue; samples++; let height = tiles ? engine.surface.sampleHeight(point.cartographic, exclude) : undefined; if (!plausible(height)) - height = engine.surface.terrainHeight(point.cartographic); + height = terrain ?? 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, + height + heightOffset, ); set.set(index, { position: point.position }); changed = true; @@ -110,8 +149,10 @@ export function createSurfacePoints(engine, { groups, request }) { const removeMoveEnd = engine.camera.moveEnd.addEventListener(refine); const removeFrame = engine.frames.preRender.addEventListener(() => { const loaded = engine.surface.tilesLoaded(); - if (loaded && !tilesLoaded) refine(); + const tiles = engine.maps?.photorealActive?.() === true; + if ((loaded && !tilesLoaded) || tiles !== photoreal) refine(); tilesLoaded = loaded; + photoreal = tiles; }); return { diff --git a/src/layers/streetLevel/render/display.js b/src/layers/streetLevel/render/display.js index efabbcfb5..c6c68fabe 100644 --- a/src/layers/streetLevel/render/display.js +++ b/src/layers/streetLevel/render/display.js @@ -13,12 +13,13 @@ // on the same engine (`display.engine`). // // The contract has no ground clamping for sprites: a sprite sits on the -// terrain, or under Google 3D on the rendered tiles (one sample each), and -// is placed again whenever the surface settles. +// terrain, or under Google 3D on the rendered tiles, measured on screen a +// few per pass and again after moves and tile loads +// (../../shared/render/surfacePoints.js). import * as geo from '../../../geo/index.js'; import { rgbaFromCss } from '../../shared/color.js'; -import { plausibleSurfaceHeight } from '../view.js'; +import { createSurfacePoints } from '../../shared/render/surfacePoints.js'; /** Lines per ground set: smaller sets build, and show, sooner. */ export const SEQUENCE_LINE_BATCH = 120; @@ -89,39 +90,6 @@ export function createStreetLevelDisplay(engine) { const live = new Set(); let serial = 0; - const tilesShown = () => engine.maps?.photorealActive?.() === true; - - /** - * Ground height (m) under a point and whether it came from the surface - * shown: the terrain on the globe; under Google 3D the rendered tiles - * (`exclude`: the sprites themselves), else the terrain. Null when - * neither answers. - */ - function groundHeight(lon, lat, exclude) { - const carto = geo.cartographic.fromDegrees(lon, lat); - let terrain; - try { - terrain = engine.surface.terrainHeightSupported - ? engine.surface.terrainHeight(carto) - : undefined; - } catch { - terrain = undefined; - } - const known = Number.isFinite(terrain); - if (!tilesShown()) return { height: known ? terrain : null, exact: known }; - let sampled; - try { - sampled = engine.surface.supported - ? engine.surface.sampleHeight(carto, exclude) - : undefined; - } catch { - sampled = undefined; - } - if (plausibleSurfaceHeight(sampled)) - return { height: sampled, exact: true }; - return { height: known ? terrain : null, exact: false }; - } - /** * A group of sprites, each `{ pickId, lon, lat, bearing, image, imageKey, * sizePx, scaleByDistance? }`: one sprite set per `imageKey`, at `order` @@ -136,8 +104,8 @@ export function createStreetLevelDisplay(engine) { let visible = true; let dirty = true; let lastPose = ''; - let settled = null; - let tiles = tilesShown(); + let surfaceGroups = []; + let placementPending = true; let destroyed = false; const wake = () => { @@ -147,10 +115,26 @@ export function createStreetLevelDisplay(engine) { }; const ownSets = () => [...banks.values()].map(({ bank }) => bank); - function place(row) { - const { height, exact } = groundHeight(row.lon, row.lat, ownSets()); - row.position = geo.cartesian3.fromDegrees(row.lon, row.lat, height ?? 0); - row.exact = exact; + // Rows change their instance index: a placement pass still queued must + // not write by the old one. + function changed() { + placementPending = true; + surfaceGroups = []; + wake(); + } + + // On the loaded terrain until measured: a sprite off screen waits for + // its measure, and a camera move can bring it into view first. + function locate(row) { + row.cartographic = geo.cartographic.fromDegrees(row.lon, row.lat); + const terrain = engine.surface.terrainHeight(row.cartographic); + row.position = geo.cartesian3.fromDegrees( + row.lon, + row.lat, + Number.isFinite(terrain) ? terrain : 0, + ); + delete row.surfaceHeight; + delete row.surfaceRevision; } function bankFor(key, first, count) { @@ -179,15 +163,6 @@ export function createStreetLevelDisplay(engine) { function paint() { if (destroyed) return; - // 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) place(row); - dirty = true; - } - settled = loaded; - tiles = nowTiles; if (!rows.size && !dirty) return; const pose = engine.camera.pose(); const signature = [ @@ -199,7 +174,6 @@ export function createStreetLevelDisplay(engine) { pose.roll, ].join('/'); if (!dirty && signature === lastPose) return; - dirty = false; lastPose = signature; const groups = new Map(); if (visible) @@ -219,6 +193,17 @@ export function createStreetLevelDisplay(engine) { record.bank.commit(); } } + if (placementPending) { + placementPending = false; + surfaceGroups = [...groups].map(([key, points]) => ({ + set: bankFor(key, points[0], points.length), + points, + })); + const exclude = ownSets(); + for (const group of surfaceGroups) group.exclude = exclude; + surface.place(); + } + dirty = false; for (const [key, group] of groups) { const bank = bankFor(key, group[0], group.length); group.forEach((row, index) => { @@ -246,13 +231,18 @@ export function createStreetLevelDisplay(engine) { } const removePaint = engine.frames.preRender.addEventListener(paint); + const surface = createSurfacePoints(engine, { + groups: () => surfaceGroups, + request: wake, + estimateGround: true, + }); const group = { /** Adds a sprite; the returned row is its handle. */ add(spec) { - const row = { ...spec, rotation: 0, position: null, exact: false }; - place(row); + const row = { ...spec, rotation: 0 }; + locate(row); rows.add(row); - wake(); + changed(); return row; }, /** Moves or turns a sprite (`lon`, `lat`, `bearing`). */ @@ -260,19 +250,19 @@ export function createStreetLevelDisplay(engine) { if (!rows.has(row)) return; const moved = lon !== row.lon || lat !== row.lat; Object.assign(row, { lon, lat, bearing }); - if (moved) place(row); - wake(); + if (moved) locate(row); + changed(); }, remove(row) { - if (rows.delete(row)) wake(); + if (rows.delete(row)) changed(); }, removeAll() { rows.clear(); - wake(); + changed(); }, show(value) { visible = value !== false; - wake(); + changed(); }, shown: () => visible, count: () => rows.size, @@ -280,6 +270,7 @@ export function createStreetLevelDisplay(engine) { if (destroyed) return; destroyed = true; removePaint(); + surface.destroy(); for (const { bank } of banks.values()) bank.destroy(); banks.clear(); rows.clear(); diff --git a/src/layers/streetLevel/render/display.test.mjs b/src/layers/streetLevel/render/display.test.mjs index ed54250e9..334f486d3 100644 --- a/src/layers/streetLevel/render/display.test.mjs +++ b/src/layers/streetLevel/render/display.test.mjs @@ -212,6 +212,16 @@ test('a sprite sits on the terrain; under Google 3D on the tiles, placed again w assert.ok(Math.abs(heightOf(google.drawn().position) - 12) < 1e-6); }); +test('a sprite off screen waits on the terrain, not on the ellipsoid', () => { + const harness = streetLevelMap({ view: true }); + harness.engine.fake.script('terrainHeight', () => 120); + harness.engine.projector.worldToWindow = () => undefined; + const { group, row, drawn } = oneSprite(harness); + assert.ok(Math.abs(heightOf(drawn().position) - 120) < 1e-6); + group.move(row, { lon: -121.4945 }); + assert.ok(Math.abs(heightOf(drawn().position) - 120) < 1e-6, 'moved'); +}); + test('a sprite without any ground sits on the ellipsoid', () => { const harness = streetLevelMap({ view: true }); harness.engine.fake.script('terrainHeight', () => undefined); @@ -318,3 +328,96 @@ test('destroying the display takes everything it still draws', () => { assert.equal(harness.engine.movers.list().length, 0); harness.map.destroy(); }); + +// Under Google 3D a settle (tilesLoaded false -> true) measures a long +// sequence's cones with sampleHeight (a pick render each on Cesium) a few per +// pass, not all in one frame. +test('a settle under Google 3D samples a bounded number of cones per frame', async (t) => { + const harness = streetLevelMap({ view: true }); + t.after(() => harness.engine.destroy()); + t.after(() => harness.map.destroy()); + const { fake } = harness.engine; + fake.script('terrainHeight', () => 12); + await harness.engine.maps.setPhotorealTiles({ kind: 'photoreal' }); + let samples = 0; + fake.script('sampleHeight', (_point, exclude) => { + assert.ok( + harness.engine.movers + .list() + .every(({ id }) => exclude.some((set) => set.id === id)), + 'samples exclude every current sprite set', + ); + samples++; + return 31; + }); + fake.script('tilesLoaded', true); + const group = harness.map.sprites('test-cones', { order: -1 }); + for (let i = 0; i < 400; i++) + group.add({ + pickId: `p:${i}`, + lon: -121.4944 + i * 1e-5, + lat: 38.5816, + bearing: 0, + image: 'data:image/svg+xml;utf8,', + imageKey: 'glyph', + sizePx: 26, + }); + assert.equal( + samples, + 0, + 'adding cones does not synchronously pick the surface', + ); + harness.frame(); + assert.ok(samples > 0 && samples <= 16, `initial pass sampled ${samples}`); + fake.script('tilesLoaded', false); + harness.frame(); + samples = 0; + fake.script('tilesLoaded', true); + harness.frame(); + assert.ok( + samples <= 16, + `one settle frame ran ${samples} sampleHeight calls for 400 cones`, + ); + harness.engine.projector.worldToWindow = () => undefined; + samples = 0; + harness.engine.camera.moveEnd.raiseEvent(); + assert.equal(samples, 0, 'off-screen cones are not sampled'); + group.destroy(); +}); + +test('slow surface samples stop at the time budget and remaining cones resume', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const harness = streetLevelMap({ view: true }); + t.after(() => harness.engine.destroy()); + t.after(() => harness.map.destroy()); + await harness.engine.maps.setPhotorealTiles({ kind: 'photoreal' }); + harness.engine.fake.script('tilesLoaded', true); + harness.engine.fake.frames(2); + harness.engine.projector.worldToWindow = () => ({ x: 100, y: 100 }); + let clock = 0; + let samples = 0; + t.mock.method(performance, 'now', () => clock); + harness.engine.fake.script('sampleHeight', () => { + samples++; + clock += 3; + return 31; + }); + const group = harness.map.sprites('slow-cones'); + for (let i = 0; i < 6; i++) + group.add({ + pickId: `cone:${i}`, + lon: 0, + lat: 0, + bearing: 0, + image: 'data:,', + imageKey: 'cone', + sizePx: 26, + }); + harness.frame(); + assert.equal(samples, 2, 'a pass stops once its samples have spent 4 ms'); + t.mock.timers.runAll(); + assert.equal(samples, 6, 'all visible cones are eventually placed'); + const before = samples; + harness.frame(); + assert.equal(samples, before, 'a parked surface is not sampled again'); +}); diff --git a/src/main.js b/src/main.js index 8c3573315..c64f3b465 100644 --- a/src/main.js +++ b/src/main.js @@ -7,6 +7,7 @@ import { describeError } from './standalone/errors.js'; import { createEngineAddress } from './standalone/engineSwitch.js'; import { DEFAULT_ENGINE_CHOICE, + ENGINE_NAMES, ENGINE_STORAGE_KEY, engineOfChoice, resolveEngineKind, @@ -30,6 +31,7 @@ const geospatial = const display = useDisplayStore(applicationPinia()); const loading = document.getElementById('loading-screen'); +let showingStartFailure = false; let application; let engineSwitch = null; // ?parkedMs= shortens how long a parked globe waits before it is released, @@ -125,56 +127,77 @@ 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() { const content = loading.querySelector('.loader-content') ?? loading; - if (content.querySelector('.engine-switch-retry')) return; + const existing = content.querySelector('.engine-switch-retry'); + if (existing) { + existing.querySelector('button')?.focus(); + return; + } const group = document.createElement('div'); group.className = 'engine-switch-retry'; group.setAttribute('role', 'group'); - group.setAttribute('aria-label', t('Globe Engine')); + const label = document.createElement('p'); + label.id = 'engine-switch-retry-label'; + label.textContent = t('Choose an engine to try again.'); + group.setAttribute('aria-labelledby', label.id); + group.append(label); for (const [kind, name] of Object.entries(ENGINE_NAMES)) { const button = document.createElement('button'); button.type = 'button'; button.dataset.engine = kind; button.textContent = name; button.setAttribute('translate', 'no'); - button.addEventListener('click', () => select(kind)); + button.addEventListener('click', () => { + // The retry's progress replaces the error: no longer an alert. + loading.querySelector('.loader-status').removeAttribute('role'); + select(kind); + }); group.append(button); } content.append(group); + group.querySelector('button').focus(); } function withdrawEngineRetry() { loading.querySelector('.engine-switch-retry')?.remove(); + if (!showingStartFailure) return; + showingStartFailure = false; + loading.classList.add('hidden'); } -function report(error) { +function report(error, kind) { + showingStartFailure = true; console.error("God's Eye View initialization failed:", error); - loading.querySelector('.loader-status').textContent = t('Error: {message}', { - message: describeError(error), - }); + const status = loading.querySelector('.loader-status'); + status.setAttribute('role', 'alert'); + status.textContent = kind + ? t('Could not start {engine}: {message}', { + engine: ENGINE_NAMES[kind] ?? kind, + message: describeError(error), + }) + : t('Error: {message}', { message: describeError(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); + if (!style) return false; + void Promise.resolve(style.initialRestorePromise).then(() => { + if (signal.aborted || liveComponents()?.controls.styleManager !== style) + return; + style._showToast( + t('Globe engine failed to start. Running {engine}: {message}', { + engine: ENGINE_NAMES[running] ?? running, + message: describeError(error), + }), + 6000, + ); + }); + return true; } function select(next) { @@ -232,15 +255,17 @@ async function start(kind, { fallback = false, rememberChoice = false } = {}) { renderers, diagnostics: session, keepFrame: keepGlobeFrame, - onSettled() { - withdrawEngineRetry(); - loading.classList.add('hidden'); - }, + onSettled: withdrawEngineRetry, address: createEngineAddress({ location, history, - flush: () => - liveComponents()?.controls.styleManager.shareLinkManager?.flush(), + flush: () => { + const live = liveComponents(); + // A pick made mid-flight hands over the flight's destination, as + // if it had landed (Cesium's completeFlight), not a midpoint. + live?.scene.engine.camera.completeFlight(); + live?.controls.styleManager.shareLinkManager?.flush(); + }, }), remember, onChange: present, @@ -250,8 +275,9 @@ async function start(kind, { fallback = false, rememberChoice = false } = {}) { offerEngineRetry(); }, }); + if (showingStartFailure) + await liveComponents()?.controls.styleManager.initialRestorePromise; withdrawEngineRetry(); - loading.classList.add('hidden'); present(); // A history visit made while the first engine started. void engineSwitch.reconcile(); @@ -262,7 +288,7 @@ async function start(kind, { fallback = false, rememberChoice = false } = {}) { await start(DEFAULT_ENGINE_CHOICE, { rememberChoice: true }); showStartFailure(error, DEFAULT_ENGINE_CHOICE); } else { - report(error); + report(error, kind); offerEngineRetry(); } } finally { diff --git a/src/maps/sentinel2.js b/src/maps/sentinel2.js index c6717058e..fd2b7f6d6 100644 --- a/src/maps/sentinel2.js +++ b/src/maps/sentinel2.js @@ -2,5 +2,6 @@ export const SENTINEL2_URL = 'https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2020_3857/default/g/{z}/{y}/{x}.jpg'; export const SENTINEL2_MAX_ZOOM = 14; +// The attribution EOX's licence requires for the 2020 mosaic (DATA_SOURCES.md). export const SENTINEL2_CREDIT = - 'EOX Maps · Sentinel-2 cloudless 2020 by EOX IT Services GmbH (contains modified Copernicus Sentinel data 2020)'; + 'EOxCloudless by EOX IT Services GmbH (Contains modified Copernicus Sentinel data 2020)'; diff --git a/src/standalone/engineSwitch.js b/src/standalone/engineSwitch.js index 7e04b2843..3cd40073a 100644 --- a/src/standalone/engineSwitch.js +++ b/src/standalone/engineSwitch.js @@ -66,6 +66,10 @@ export function createEngineAddress({ location, history, flush = () => {} }) { * (`addressLayerIds`): the layer-state coordinator keeps it running as it * was where the incoming address agrees (always after a pick) and follows a * visited entry where it differs (handoffTargetEnabled). + * @param {import('../app/layerSession.js').LayerSession} session + * @param {object} [options] + * @param {Iterable | null} [options.addressLayerIds] Layers listed by the outgoing address. + * @param {boolean} [options.visit=false] Restore a history entry's layer options; a picker change keeps the live options. */ export function handOffSession( session, diff --git a/src/testSupport/fleetTickFixture.mjs b/src/testSupport/fleetTickFixture.mjs new file mode 100644 index 000000000..12f99c941 --- /dev/null +++ b/src/testSupport/fleetTickFixture.mjs @@ -0,0 +1,106 @@ +// A layer's real fleet tick and horizon culling (flights or military +// rendering.js) over one contact, with motion and presentation injected: no +// WebGL scene. The camera is 100 m over (0°, 0°); the contact, on the ground +// at (1°, 0°), is 111 km away: beyond the camera's horizon, but inside the +// model keep radius. + +import * as Cesium from 'cesium'; +import { horizonVisible } from '../globe/cameraMath.js'; +import { FLEET_DR_INTERVAL_MS } from '../layers/flights/policy.js'; + +/** + * @param {{ createFlightState: Function, createRendering: Function }} layer + * The layer's state.js and rendering.js factories. + */ +export function fleetTickFixture({ createFlightState, createRendering }) { + let now = 1000; + let reckonings = 0; + const services = { + groundSnap: { createGroundSnap: () => ({ clear() {} }) }, + militaryRegistry: { isMilitaryIcao: () => false }, + aircraftPresentation: { isTr3b: () => false }, + camera: {}, + focus: { + focusNowMs: () => now, + getFocusTarget: () => null, + nearFarScalarValueAtDistance: () => 1, + advanceProjectedSpriteFocus: () => ({ factor: 1 }), + }, + recession: { + applyAircraftBillboardTreatment: () => ({ + factors: { scale: 1 }, + alpha: 1, + }), + }, + }; + const flightState = createFlightState({ services }); + const camera = { + positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100), + positionCartographic: { height: 100 }, + heading: 0, + pitch: 0, + roll: 0, + frustum: { + computeCullingVolume: () => ({ + computeVisibility: () => Cesium.Intersect.INSIDE, + }), + }, + }; + const id = 'abc123'; + const billboard = { + show: false, + position: Cesium.Cartesian3.fromDegrees(1, 0, 0), + }; + const removed = []; + // The renderer's fleet, models and view, as the Cesium renderer gives + // them; no model loads. + flightState._renderer = { + fleet: { visible: () => true }, + models: { + live: () => true, + loadFleet: () => Promise.reject(new Error('no models here')), + remove: (model) => removed.push(model), + }, + view: { + camera, + scene: {}, + horizon: () => ({ + isPointVisible: (position) => + horizonVisible(camera.positionWC, position), + }), + frustumTester: () => () => true, + }, + }; + flightState._billboards.set(id, billboard); + flightState.records.data.set(id, {}); + flightState._models3dEnabled = false; + // Far cockpit contacts avoid glyph rasterization, keeping the fixture DOM-free. + flightState._cockpitContactMode = true; + const parts = { + tracking: { _refreshCockpitNearContacts() {} }, + enrichment: { _requestTypeEnrichment() {} }, + motion: { + _deadReckon() { + reckonings++; + return billboard.position; + }, + _floorGroundedDisplayPosition(id, info, position) { + return position; + }, + }, + }; + const rendering = createRendering({ flightState, services, parts }); + return { + flightState, + camera, + billboard, + id, + /** Fleet models the tick released. */ + removed, + reckonings: () => reckonings, + tick() { + now += FLEET_DR_INTERVAL_MS; + rendering._fleetTick(); + }, + }; +} diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index 2bc5ed764..5936b03f5 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -297,7 +297,6 @@ test('module analysis can tell dynamic imports from static ones', () => { { imports: ['one', 'two', 'three', 'four'], browser: [], - dynamicImports: ['three', 'four'], staticImports: ['one', 'two'], }, ); diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 7badc7ea8..94f210bd7 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -1295,12 +1295,13 @@ export class ShellFacade { } /** - * Displays a temporary toast notification for 2 seconds. + * Displays a temporary toast notification. * @param {string} message - Text to show in the toast. + * @param {number} [durationMs=2000] How long the notice stays visible. * @returns {void} */ - _showToast(message) { - return this._feedback._showToast(message); + _showToast(message, durationMs) { + return this._feedback._showToast(message, durationMs); } /** One 3D toggle drives BOTH aircraft layers (commercial + military) so all planes flip together. */ diff --git a/src/ui/shellFeedback.js b/src/ui/shellFeedback.js index 8b29220f9..1c6ea7010 100644 --- a/src/ui/shellFeedback.js +++ b/src/ui/shellFeedback.js @@ -374,9 +374,9 @@ export class ShellFeedback { this._loadingFeedbackTicker = null; } - _showToast(message) { + _showToast(message, durationMs) { if (this.destroyed) return; - this._toast.show(message); + this._toast.show(message, durationMs); } destroy() { if (this.destroyed) return; diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 270a4048d..c4b37e160 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -436,14 +436,27 @@ html:not([lang='en']):not(:lang(zh)):not(:lang(ja)) animation: pulse 2s ease-in-out infinite; } +/* A start error is steady text, not pulsing progress. */ +.loader-status[role='alert'] { + color: var(--text-primary); + animation: none; +} + /* After a failed globe switch: start an engine again without a reload. */ .engine-switch-retry { display: flex; + flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 16px; } +.engine-switch-retry p { + flex-basis: 100%; + font-size: 12px; + color: var(--text-primary); +} + .engine-switch-retry button { font-family: var(--font-mono); font-size: 11px; diff --git a/src/ui/toast.js b/src/ui/toast.js index 0df985467..87fde64fd 100644 --- a/src/ui/toast.js +++ b/src/ui/toast.js @@ -25,11 +25,11 @@ export function createToast({ store.publishToast({ ...store.toast, visible: false }); }; return { - /** @param {string} message */ - show(message) { + /** @param {string} message @param {number} [dwellMs] */ + show(message, dwellMs = durationMs) { store.publishToast({ text: message, visible: true, shown: ++shown }); clearTimeout(timer); - timer = setTimeout(hide, durationMs); + timer = setTimeout(hide, dwellMs); }, /** Hide the toast now and drop the timer. */ hide, diff --git a/src/ui/toastView.test.mjs b/src/ui/toastView.test.mjs index e82dd0b4a..130d72d81 100644 --- a/src/ui/toastView.test.mjs +++ b/src/ui/toastView.test.mjs @@ -7,6 +7,8 @@ import { createApp, nextTick } from 'vue'; import { createPinia } from 'pinia'; import { useStatusStore } from './stores/status.js'; import { createToast } from './toast.js'; +import { ShellFeedback } from './shellFeedback.js'; +import { ShellFacade } from './shellFacade.js'; import SceneChromeView from './views/SceneChromeView.vue'; function fixture(t) { @@ -77,3 +79,21 @@ test('a repeated message is written again so the live region announces it', asyn assert.ok(toast().firstChild !== first, 'the repeat is a new text node'); assert.equal(toast().classList.contains('visible'), true); }); + +test('an engine failure can stay readable longer than an ordinary shell toast', async (t) => { + const { store, toast } = fixture(t); + const feedback = new ShellFeedback({ readLayers: () => [], store }); + t.after(() => feedback.destroy()); + const shell = { _feedback: feedback }; + ShellFacade.prototype._showToast.call(shell, 'Engine failed', 6000); + t.mock.timers.tick(2000); + await nextTick(); + assert.equal(toast().classList.contains('visible'), true); + t.mock.timers.tick(4000); + await nextTick(); + assert.equal(toast().classList.contains('visible'), false); + ShellFacade.prototype._showToast.call(shell, 'Link copied'); + t.mock.timers.tick(2000); + await nextTick(); + assert.equal(toast().classList.contains('visible'), false); +}); diff --git a/src/ui/views/CommandDockView.vue b/src/ui/views/CommandDockView.vue index eb4d6ac47..5bedf76fb 100644 --- a/src/ui/views/CommandDockView.vue +++ b/src/ui/views/CommandDockView.vue @@ -1,5 +1,6 @@