From 0ef29781e58208ae94e7a1e2927c6492d4c8025a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 9 Nov 2026 21:34:42 +0400 Subject: [PATCH] Fix the review findings across the engines, layers, data and QA Navara now matches Cesium where the review found it diverging: a flight holds the camera inputs, camera.changed counts turns as Cesium does, render requests made during a frame survive it, follow pinches zoom, ground widths follow the pixel ratio and Cyber sonar reaches glyph caches and models. On Cesium, a staged imagery swap keeps loading tiles, setView replaces a running flight, and a cleared weather detail goes away. The contract gains MoverSet.failed, maps.terrainRevision, render.maxTextureSize and surface.sampleTerrainMostDetailed on every engine, so layers retry failed models, key road heights by terrain, size weather textures to the GPU and place Bhote Koshi cards on detailed terrain. Engine switches keep the live view, follows, focus, layer options and cards: a pick hands over the exact pose, applies no address options and pans no carried-over card; a history visit still applies its entry. Layers put GhostMaps pins, cable landings, OSM Places and directions on the measured surface without per-frame sampling, draw the Cockpit contact dot, hide disabled CCTV on attach, and keep ALPR running outside secure contexts. KML imports draw raised polygon fills, rotated and coloured ground overlays and north-referenced icon headings, and report files with nothing to draw. QA scripts read the contract instead of Cesium internals (test:track is 109/109 again), wait for the camera to rest before clicking, and give cold switches and parked engines realistic time. Docs follow the code. --- DATA_SOURCES.md | 4 +- THIRD_PARTY_NOTICES.md | 3 +- build/panel.js | 28 +- build/panelNavara.js | 12 + build/panelResources.test.mjs | 41 ++ build/vite.js | 14 + docs/CODE-BOUNDARIES.md | 21 +- docs/CURRENT-STATE.md | 256 +++++---- docs/GLOBE-PRIMITIVES.md | 66 ++- scripts/check-cesium-load-time.mjs | 9 +- scripts/check-cesium-load-time.test.mjs | 24 + scripts/check-import-directions.mjs | 35 +- scripts/module-analysis.mjs | 12 +- scripts/package-boundaries.json | 4 + scripts/qa-cables-overlay.mjs | 5 +- scripts/qa-cctv-v2.mjs | 7 +- scripts/qa-directions.mjs | 3 +- scripts/qa-engine-switch.mjs | 4 +- scripts/qa-fire-camera-switch.mjs | 49 +- scripts/qa-gbfs-names.mjs | 111 ++-- scripts/qa-ground-layer-switch.mjs | 46 +- scripts/qa-installation-polish.mjs | 24 +- scripts/qa-label-readability.mjs | 4 +- scripts/qa-labels.mjs | 4 +- scripts/qa-layer-handoff.mjs | 4 +- scripts/qa-military-names.mjs | 26 +- scripts/qa-navara-draw.mjs | 4 +- scripts/qa-navara-geo-import.mjs | 10 +- scripts/qa-overlay-baseline.mjs | 18 +- scripts/qa-perf.mjs | 10 +- scripts/qa-strict-context-loss.mjs | 4 +- scripts/qa-strict-failure-matrix.mjs | 8 +- scripts/qa-strict-harness.mjs | 89 ++- scripts/qa-strict-hidden-switch.mjs | 10 +- scripts/qa-strict-parked-release.mjs | 16 +- scripts/qa-strict-rapid-switch.mjs | 6 +- scripts/qa-strict-session.test.mjs | 31 +- scripts/qa-traffic-motion.mjs | 14 +- scripts/qa-transit-browser.mjs | 2 +- scripts/qa-transit-heading.mjs | 5 +- scripts/qa-transit-recovery.mjs | 5 +- scripts/qa-transit.mjs | 2 +- scripts/qa-urban-engine-switch.mjs | 46 +- scripts/qa-wildlife-switch.mjs | 32 +- scripts/track-regression.mjs | 81 ++- scripts/vue-scripts.mjs | 10 + src/annotations/navaraAnnotationRenderer.js | 1 + .../navaraAnnotationRenderer.test.mjs | 37 ++ src/annotations/resolver.js | 16 +- src/annotations/resolverHighTerrain.test.mjs | 63 +++ src/app/constructCatalog.js | 512 +++++++----------- src/app/controls.js | 2 - src/app/engineChoice.js | 15 +- src/app/engineVisitStorage.test.mjs | 30 + src/app/layerSession.test.mjs | 1 + src/app/layers/aisLiveVessels.js | 1 - src/app/layers/alprCameras.js | 5 - src/app/layers/bikeshare.js | 5 - src/app/layers/cctv.js | 5 - src/app/layers/flights.js | 1 - src/app/layers/militaryFlights.js | 1 - src/app/layers/militaryInstallations.js | 5 - src/app/layers/qaSources.js | 6 +- src/app/layers/qaSources.test.mjs | 16 + src/app/layers/radio.js | 5 - src/app/layers/rocketLaunches.js | 1 - src/app/layers/satellites.js | 1 - src/app/layers/traffic.js | 5 - src/app/loadCesium.js | 6 +- src/app/loadCesium.test.mjs | 8 + src/app/mainStartup.test.mjs | 120 ++++ src/app/navaraMode.js | 11 +- src/app/tools.js | 9 +- src/celestialRing.js | 8 +- src/celestialRingFocus.test.mjs | 34 ++ src/cockpitCloudEffects.js | 4 +- src/cyberSonarEngine.js | 18 +- src/cyberSonarEngine.test.mjs | 33 ++ src/data/bhoteKoshiEvent.js | 60 +- src/data/bhoteKoshiEvent.test.mjs | 179 ++++++ src/data/cameraArrival.test.mjs | 27 + src/data/geoFileImportDegenerate.test.mjs | 35 ++ src/data/geoImportDisplay.js | 279 ++++++++-- src/data/geoImportDisplay.test.mjs | 225 +++++++- src/data/geoImportIconBudget.test.mjs | 97 ++++ src/data/geoImportKml.js | 2 + src/data/geoImportKml.test.mjs | 12 + src/data/geoImportPins.js | 7 +- src/data/geoImportRaisedPolygon.test.mjs | 112 ++++ src/data/geoImportRecords.js | 14 +- src/data/layerStateCoordinator.js | 50 +- src/data/layerStateHandoffOptions.test.mjs | 130 +++++ src/data/lifecycle.js | 5 +- src/data/trackedAircraftScenes.test.mjs | 18 +- src/data/trackedReadout.js | 1 + src/data/trackedReadout.visualAnchor.test.mjs | 52 ++ src/data/vesselLabels.js | 24 - src/geo/matrix4.js | 6 - src/geo/scaleToGeodeticSurface.js | 2 - src/globe/cesium/camera.js | 6 + src/globe/cesium/camera.test.mjs | 36 ++ src/globe/cesium/ground.js | 8 +- src/globe/cesium/ground.test.mjs | 27 + src/globe/cesium/imagery.js | 9 + src/globe/cesium/imageryStageCredit.test.mjs | 162 ++++++ .../cesium/imageryStageRequests.test.mjs | 162 ++++++ src/globe/cesium/maps.js | 8 + src/globe/cesium/maps.test.mjs | 13 + src/globe/cesium/movers.js | 24 +- src/globe/cesium/movers.test.mjs | 30 + src/globe/cesium/render.js | 2 + src/globe/cesium/surface.js | 17 + src/globe/cesium/surface.test.mjs | 15 + src/globe/cesium/testing/fakeViewer.mjs | 11 +- src/globe/contract.js | 27 +- src/globe/ground.js | 21 +- src/globe/ground.test.mjs | 38 ++ src/globe/imagery.js | 28 +- src/globe/imageryAntimeridian.test.mjs | 46 ++ src/globe/imageryDetailCancellation.test.mjs | 32 ++ src/globe/movers.js | 5 +- src/globe/navara/cameraChanged.test.mjs | 141 +++++ src/globe/navara/cameraModel.js | 38 +- src/globe/navara/cameraModel.test.mjs | 23 + src/globe/navara/createNavaraEngine.js | 93 +++- src/globe/navara/createNavaraEngine.test.mjs | 129 ++++- src/globe/navara/flightInput.test.mjs | 186 +++++++ src/globe/navara/followGestures.js | 33 +- src/globe/navara/followPinch.test.mjs | 68 +++ src/globe/navara/ground/index.js | 1 + src/globe/navara/ground/index.test.mjs | 19 + src/globe/navara/ground/pack.js | 22 +- src/globe/navara/ground/pack.test.mjs | 19 + src/globe/navara/ground/pass.js | 23 +- src/globe/navara/imagery/index.js | 17 +- src/globe/navara/imagery/index.test.mjs | 54 +- src/globe/navara/imagery/pass.js | 4 +- src/globe/navara/input.js | 49 +- src/globe/navara/input.test.mjs | 16 +- src/globe/navara/inputPinch.test.mjs | 104 ++++ src/globe/navara/maps.js | 41 +- src/globe/navara/maps.test.mjs | 46 ++ src/globe/navara/movers/index.js | 2 + src/globe/navara/movers/modelMover.js | 8 +- src/globe/navara/movers/modelMover.test.mjs | 10 + src/globe/navara/movers/modelTint.js | 12 +- src/globe/navara/movers/moverMesh.js | 27 +- src/globe/navara/overlayPass.js | 11 + src/globe/navara/overlaySonar.test.mjs | 110 ++++ src/globe/navara/sonar.js | 2 +- src/globe/navara/surface.js | 9 +- src/globe/testing/contractSuite.mjs | 25 + src/globe/testing/fakeEngine.js | 28 +- src/hud.js | 2 +- src/i18n/locales/ar.json | 9 +- src/i18n/locales/de.json | 9 +- src/i18n/locales/es.json | 9 +- src/i18n/locales/fr.json | 9 +- src/i18n/locales/it.json | 9 +- src/i18n/locales/ja.json | 9 +- src/i18n/locales/pt.json | 9 +- src/i18n/locales/ru.json | 9 +- src/i18n/locales/uk.json | 9 +- src/i18n/locales/zh-CN.json | 9 +- src/i18n/locales/zh-Hant.json | 9 +- .../render/cockpitContactDot.test.mjs | 68 +++ src/layers/aircraft/render/contract.js | 8 +- src/layers/aircraft/render/display.js | 52 +- src/layers/aircraft/render/display.test.mjs | 3 +- src/layers/aircraft/sceneBinding.js | 29 +- src/layers/aircraft/sceneBinding.test.mjs | 22 +- src/layers/aircraft/snapshotRestore.test.mjs | 45 ++ .../aircraft/trackedModelFailure.test.mjs | 183 +++++++ src/layers/aircraft/tracking.js | 16 +- src/layers/alpr/overlay.js | 4 +- .../alpr/overlayInsecureContext.test.mjs | 45 ++ src/layers/cctv/attachDisabled.test.mjs | 64 +++ src/layers/cctv/ingestion.js | 11 + src/layers/cctv/ingestion.test.mjs | 23 + src/layers/cctv/lifecycle.js | 29 +- src/layers/cctv/render/display.js | 1 + src/layers/cctv/rendering.js | 2 +- src/layers/cctv/viewFovy.test.mjs | 57 ++ src/layers/directions/render/display.js | 7 + src/layers/directions/render/display.test.mjs | 27 + src/layers/flights/lifecycle.js | 10 +- src/layers/flights/queries.js | 8 +- src/layers/flights/rendering.js | 25 +- src/layers/flights/snapshotRenderer.js | 19 +- src/layers/flights/state.js | 16 - src/layers/flights/testing.js | 15 + src/layers/ghostMaps/edgeCases.test.mjs | 5 + src/layers/ghostMaps/index.js | 31 +- src/layers/ghostMaps/pendingPaint.test.mjs | 167 ++++++ src/layers/ghostMaps/render/display.js | 14 +- src/layers/ghostMaps/render/display.test.mjs | 76 +++ src/layers/infrastructure/render/display.js | 1 + .../render/inViewContext.test.mjs | 84 +++ src/layers/launches/lifecycle.js | 7 +- .../launches/lifecycleSnapshot.test.mjs | 54 ++ src/layers/launches/replay.js | 5 +- src/layers/launches/replay.test.mjs | 22 + src/layers/launches/selection.js | 12 +- src/layers/launches/selection.test.mjs | 25 + src/layers/launches/state.js | 1 + src/layers/localAdsb/models.js | 2 +- src/layers/localAdsb/models.test.mjs | 50 ++ src/layers/military/lifecycle.js | 10 +- src/layers/military/queries.js | 8 +- src/layers/military/rendering.js | 25 +- src/layers/military/snapshotRenderer.js | 19 +- src/layers/military/state.js | 16 - src/layers/military/testing.js | 15 + src/layers/osmPlaces/render/display.js | 101 +++- src/layers/osmPlaces/render/display.test.mjs | 56 +- .../recentImagery/detachedGetStats.test.mjs | 46 ++ src/layers/recentImagery/index.js | 1 + src/layers/satellites/controls.js | 9 +- src/layers/satellites/detachedToggle.test.mjs | 98 ++++ src/layers/satellites/lifecycle.js | 13 +- src/layers/shared/render/moverBank.js | 4 + src/layers/shared/render/surfacePoints.js | 127 +++++ .../shared/render/surfacePoints.test.mjs | 75 +++ src/layers/streetLevel/render/display.js | 7 +- .../streetLevel/render/display.test.mjs | 6 +- src/layers/submarineCables/render/display.js | 32 +- .../submarineCables/render/display.test.mjs | 50 ++ .../traffic/render/belowEllipsoid.test.mjs | 48 ++ src/layers/traffic/render/display.js | 1 - src/layers/traffic/surface.js | 19 +- src/layers/traffic/surface.test.mjs | 9 +- src/layers/transit/render/style.js | 2 +- src/layers/vessels/lifecycle.js | 3 +- src/layers/weather/render/display.js | 1 + src/layers/weather/shellRendering.js | 17 +- src/layers/weather/shellRendering.test.mjs | 15 + src/main.js | 102 +++- src/mapStartup.test.mjs | 2 +- src/maps/defaultSources.js | 6 +- src/maps/sourceFactories.test.mjs | 2 +- src/overlays/worldOverlay.js | 6 - src/scenes/dataPacks/shapes.js | 4 - src/scenes/director.js | 1 + src/scenes/director.test.mjs | 37 ++ src/scenes/interactions.js | 2 +- src/scenes/interactionsSceneSwitch.test.mjs | 61 +++ src/services/meshFloorSampler.js | 10 - src/sharelink.js | 21 +- src/sharelinkSwitchPose.test.mjs | 77 +++ src/standalone/application.js | 3 +- src/standalone/engineSwitch.js | 6 +- src/standalone/sceneSwitcher.js | 3 +- src/tooling/importDirections.test.mjs | 30 + src/tooling/panelBuild.test.mjs | 2 +- src/ui/applicationShell.js | 2 - src/ui/cameraOrientationControls.js | 52 +- src/ui/cameraOrientationControls.test.mjs | 1 + src/ui/cameraOrientationNativeFollow.test.mjs | 98 ++++ src/ui/cockpitController.js | 2 +- src/ui/cockpitController.test.mjs | 20 + src/ui/commandDock.test.mjs | 33 ++ src/ui/geoFileImport.js | 1 + src/ui/geoFileImportRedraw.test.mjs | 75 +++ src/ui/locationNavigation.js | 11 +- src/ui/navigationController.js | 8 +- src/ui/shareRestoration.js | 7 +- src/ui/shellScene.js | 4 +- src/ui/styles/controls.css | 16 + src/ui/views/CommandDockView.vue | 10 +- src/ui/wildlifeSightingCard.js | 2 +- src/ui/wildlifeSightingCard.test.mjs | 28 + src/voice/areaActions.js | 9 +- src/voice/gevActions.js | 8 +- src/voice/gevRealtime.js | 11 + src/voice/hudPlaceLabels.test.mjs | 14 + src/voice/sceneVoice.test.mjs | 30 + 276 files changed, 7217 insertions(+), 1517 deletions(-) create mode 100644 build/panelNavara.js create mode 100644 build/panelResources.test.mjs create mode 100644 scripts/vue-scripts.mjs create mode 100644 src/annotations/resolverHighTerrain.test.mjs create mode 100644 src/app/engineVisitStorage.test.mjs create mode 100644 src/app/mainStartup.test.mjs create mode 100644 src/celestialRingFocus.test.mjs create mode 100644 src/data/geoFileImportDegenerate.test.mjs create mode 100644 src/data/geoImportIconBudget.test.mjs create mode 100644 src/data/geoImportRaisedPolygon.test.mjs create mode 100644 src/data/layerStateHandoffOptions.test.mjs create mode 100644 src/data/trackedReadout.visualAnchor.test.mjs create mode 100644 src/globe/cesium/imageryStageCredit.test.mjs create mode 100644 src/globe/cesium/imageryStageRequests.test.mjs create mode 100644 src/globe/imageryAntimeridian.test.mjs create mode 100644 src/globe/imageryDetailCancellation.test.mjs create mode 100644 src/globe/navara/cameraChanged.test.mjs create mode 100644 src/globe/navara/flightInput.test.mjs create mode 100644 src/globe/navara/followPinch.test.mjs create mode 100644 src/globe/navara/inputPinch.test.mjs create mode 100644 src/globe/navara/overlaySonar.test.mjs create mode 100644 src/layers/aircraft/render/cockpitContactDot.test.mjs create mode 100644 src/layers/aircraft/snapshotRestore.test.mjs create mode 100644 src/layers/aircraft/trackedModelFailure.test.mjs create mode 100644 src/layers/alpr/overlayInsecureContext.test.mjs create mode 100644 src/layers/cctv/attachDisabled.test.mjs create mode 100644 src/layers/cctv/viewFovy.test.mjs create mode 100644 src/layers/ghostMaps/pendingPaint.test.mjs create mode 100644 src/layers/infrastructure/render/inViewContext.test.mjs create mode 100644 src/layers/launches/selection.test.mjs create mode 100644 src/layers/localAdsb/models.test.mjs create mode 100644 src/layers/recentImagery/detachedGetStats.test.mjs create mode 100644 src/layers/satellites/detachedToggle.test.mjs create mode 100644 src/layers/shared/render/surfacePoints.js create mode 100644 src/layers/shared/render/surfacePoints.test.mjs create mode 100644 src/layers/traffic/render/belowEllipsoid.test.mjs create mode 100644 src/scenes/interactionsSceneSwitch.test.mjs create mode 100644 src/sharelinkSwitchPose.test.mjs create mode 100644 src/ui/cameraOrientationNativeFollow.test.mjs create mode 100644 src/ui/geoFileImportRedraw.test.mjs diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index b5d43c662..223917a8a 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -5,7 +5,7 @@ God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the How to read this: - **The non-permissive datasets are carved out, not omitted.** Some bundled data (e.g. TeleGeography, CC BY-NC-SA) isn't MIT-compatible. Rather than hide it, we **bundle it with a clear license carve-out** so the app works out of the box — but it stays under the provider's terms. -- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder. +- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder. **EOX Sentinel-2 cloudless 2020** is also **NonCommercial** (CC BY-NC-SA 4.0): remove that basemap or obtain a commercial licence from EOX. - **Attribution is shown in-app** and listed here. Keep it intact. The required Google/Cesium credit renders on the on-globe credit line (bottom-left, `#cesium-credits`), and every per-layer credit below is registered into the expandable **"Data attribution"** lightbox on that line (`src/data/dataCredits.js` → `viewer.creditDisplay.addStaticCredit`). Both stay visible in clean-view and recording modes. - **Bundled model attribution lives beside the model files.** [`public/models/README.md`](public/models/README.md) records each shipped model's creator, source, license, and modification status. @@ -33,7 +33,7 @@ How to read this: | **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) | | **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" | | **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) | -| **EOX Sentinel-2 cloudless 2020** | Optional satellite basemap in Cesium and Navara. Navara uses daylight for terrain inspection. | [EOX Maps](https://maps.eox.at/) and [imagery licence](https://cloudless.eox.at/) | Sentinel-2 cloudless 2020 by EOX IT Services GmbH (contains modified Copernicus Sentinel data 2020) | +| **EOX Sentinel-2 cloudless 2020** | Optional satellite basemap in Cesium and Navara. Navara uses daylight for terrain inspection. | [EOX Maps](https://maps.eox.at/); [CC BY-NC-SA 4.0](https://cloudless.eox.at/license-non-commercial). Commercial use requires a commercial licence from EOX. | [EOxCloudless](https://cloudless.eox.at/) by [EOX IT Services GmbH](https://eox.at/) (Contains modified Copernicus Sentinel data 2020) | | **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) | | **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" | | **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4defc06c1..fe8afd367 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -52,7 +52,8 @@ resolved dependency inventory remains in `package-lock.json`. are adapted from `@cesium/engine/Source/Core` (`Math.js`, `Ellipsoid.js`, `scaleToGeodeticSurface.js`, `Cartographic.js`, `Cartesian2.js`, `Cartesian3.js`, `Matrix3.js`, `Matrix4.js`, `Quaternion.js`, - `Transforms.js`) and say so in their first + `Transforms.js`, `BoundingSphere.js`, `EllipsoidGeodesic.js`, `JulianDate.js`, + `Simon1994PlanetaryPositions.js`) and say so in their first line. The changes: the classes' static functions became module functions, vectors, geodetic positions and quaternions became plain-data instances of module-private constructors (own enumerable fields only, no methods), diff --git a/build/panel.js b/build/panel.js index 03610964f..8c1d698d7 100644 --- a/build/panel.js +++ b/build/panel.js @@ -6,6 +6,9 @@ * PANEL_BASE next to the app. */ +import cesium from 'vite-plugin-cesium'; +import { fileURLToPath } from 'node:url'; + // The panel loads these paths (src/tools/globePanel.js); a test keeps the // two in step. export const PANEL_BASE = '/panel/'; @@ -18,6 +21,27 @@ export function panelBuildConfig(config) { return { ...config, base: PANEL_BASE, + plugins: (config.plugins ?? []).map((plugin) => + plugin?.name === 'vite-plugin-cesium' ? cesium() : plugin, + ), + resolve: { + ...config.resolve, + alias: [ + // The panel runs Cesium: the Navara mode and Navara's wind mesh + // (the panel's only paths to Navara and three) become stubs. + { + find: /^(?:\.\/|.*\/app\/)navaraMode\.js$|\/globe\/navara\/windFlowMesh\.js$/, + replacement: fileURLToPath( + new URL('./panelNavara.js', import.meta.url), + ), + }, + ...(Array.isArray(config.resolve?.alias) + ? config.resolve.alias + : Object.entries(config.resolve?.alias ?? {}).map( + ([find, replacement]) => ({ find, replacement }), + )), + ], + }, build: { ...config.build, outDir: PANEL_OUT_DIR, @@ -26,9 +50,7 @@ export function panelBuildConfig(config) { cssCodeSplit: false, rollupOptions: { ...config.build?.rollupOptions, - // The panel inlines every dynamic import into its one script, the - // experimental Navara mode included, so the guard that keeps Navara - // out of the app's initial chunks does not apply here. + // The panel is a single chunk; its alias excludes the Navara engine. plugins: (config.build?.rollupOptions?.plugins ?? []).filter( (plugin) => plugin?.name !== 'gev-initial-chunk-guard', ), diff --git a/build/panelNavara.js b/build/panelNavara.js new file mode 100644 index 000000000..16a2dc4be --- /dev/null +++ b/build/panelNavara.js @@ -0,0 +1,12 @@ +// The panel host transports Cesium assets; Navara workers need a web page. +export function createNavaraScene() { + throw new Error('Navara is unavailable in the panel'); +} + +export function navaraToolBackend() { + throw new Error('Navara is unavailable in the panel'); +} + +export function createWindFlowMesh() { + throw new Error('Navara is unavailable in the panel'); +} diff --git a/build/panelResources.test.mjs b/build/panelResources.test.mjs new file mode 100644 index 000000000..438226690 --- /dev/null +++ b/build/panelResources.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createBrowserViteConfig } from './vite.js'; +import { panelBuildConfig } from './panel.js'; + +test('panel HTML lists Cesium assets for the host resource loader', () => { + const config = panelBuildConfig(createBrowserViteConfig()); + const plugin = config.plugins.find( + ({ name }) => name === 'vite-plugin-cesium', + ); + plugin.config(config, { command: 'build' }); + assert.equal(typeof plugin.transformIndexHtml, 'function'); + const tags = plugin.transformIndexHtml(); + assert.ok(tags.some(({ attrs }) => attrs.src === '/panel/cesium/Cesium.js')); + assert.ok( + tags.some( + ({ attrs }) => attrs.href === '/panel/cesium/Widgets/widgets.css', + ), + ); +}); + +test('the panel replaces Navara entry points and retains browser aliases', () => { + const browser = createBrowserViteConfig(); + const config = panelBuildConfig(browser); + const alias = config.resolve.alias.find(({ find }) => + find.test('./navaraMode.js'), + ); + assert.ok(alias.replacement.endsWith('/build/panelNavara.js')); + // Navara's wind mesh imports three; the Cesium wind backend stays. + assert.ok(alias.find.test('../../globe/navara/windFlowMesh.js')); + assert.equal(alias.find.test('../../globe/cesium/windFlow.js'), false); + assert.ok(config.resolve.alias.includes(browser.resolve.alias[0])); +}); + +test('Three imports and requires share its ESM entry', () => { + const { resolve } = createBrowserViteConfig(); + assert.deepEqual(resolve.dedupe, ['three']); + const alias = resolve.alias.find(({ find }) => find.test('three')); + assert.ok(alias.replacement.endsWith('/three/build/three.module.js')); + assert.equal(alias.find.test('three/addons/loaders/GLTFLoader.js'), false); +}); diff --git a/build/vite.js b/build/vite.js index ac00a403a..c3abf2f7d 100644 --- a/build/vite.js +++ b/build/vite.js @@ -199,6 +199,20 @@ export function createBrowserViteConfig({ // A production build must not clean the dependency cache a running dev // server is still serving optimized module URLs from. ...(command === 'build' ? { cacheDir: 'node_modules/.vite-build' } : {}), + resolve: { + dedupe: ['three'], + alias: [ + { + find: /^three$/, + replacement: fileURLToPath( + new URL( + '../node_modules/three/build/three.module.js', + import.meta.url, + ), + ), + }, + ], + }, optimizeDeps: { // First reached through the SDR worker or a dynamic import. Pre-bundle // them at startup so first use cannot invalidate already-transformed diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 8ab4bc9f4..292ab077c 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -80,10 +80,17 @@ self-package imports, symlinks and unreachable files. Runtime SDK imports are restricted by directory, including unreachable modules: `cesium` and `@cesium/*` belong in `src/globe/cesium/`; `three`, `postprocessing` and `@navaramap/*` belong in `src/globe/navara/`. Package -subpaths, re-exports and literal dynamic imports obey the same rule. Tests and -Node test support retain their existing exemptions. The production script -loader is `src/app/loadCesium.js`; it imports no SDK package and needs no -exception. There is no blanket exception for layer `render/` directories. +subpaths, re-exports and literal dynamic imports obey the same rule, and the +``, + 'src/View.vue', + ).map(({ line, name }) => `${line}:${name}`), + ['4:Color'], + ); +}); diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index f520e9c76..ab5bcaa17 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -8,7 +8,7 @@ import { analyzeModule } from './module-analysis.mjs'; const builtins = new Set( builtinModules.flatMap((name) => [name, `node:${name}`]), ); -const code = /\.[mc]?js$/; +const code = /\.(?:[mc]?js|vue)$/; const entry = (file) => file === 'src/main.js' || file.startsWith('src/standalone/'); const tests = (file) => @@ -30,12 +30,6 @@ const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file); const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/; // Navara renders through three.js and its postprocessing peer. const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/; -// The modules that load Navara renderers, each through import(). -const NAVARA_RENDERER_LOADERS = new Set([ - 'src/app/navaraMode.js', - 'src/app/layers/renderers.js', -]); -const navaraLayerRenderer = (file) => /\/render\/navara[^/]*\.js$/.test(file); const renderer = (file) => /^src\/(?:ui|app|standalone|overlays)\//.test(file) || /^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test( @@ -125,6 +119,7 @@ export function checkImportDirections(root) { try { analysis = analyzeModule(readFileSync(resolved, 'utf8'), { dynamic: true, + filename: file, }); } catch (error) { report(file, error.message); @@ -188,27 +183,12 @@ export function checkImportDirections(root) { if ( to.startsWith('src/globe/navara/') && !file.startsWith('src/globe/navara/') && - !analysis.dynamicImports.includes(specifier) + analysis.staticImports.includes(specifier) ) report( file, `The Navara engine loads only through a dynamic import(): ${to}`, ); - // Like the engine, a layer's Navara renderer reaches the app only - // through an import() of the Navara mode (src/app/navaraMode.js) or - // of the layer manifest (src/app/layers/renderers.js). - if ( - navaraLayerRenderer(to) && - !navaraLayerRenderer(file) && - !( - NAVARA_RENDERER_LOADERS.has(file) && - analysis.dynamicImports.includes(specifier) - ) - ) - report( - file, - `A Navara layer renderer loads only through the Navara mode's dynamic import(): ${to}`, - ); if (file.startsWith('src/') && to.startsWith('server/')) report(file, `Browser imports server: ${to}`); if ( @@ -322,8 +302,8 @@ export function checkImportDirections(root) { } for (const file of files.filter(globeContract)) globe(file); // Each engine graph stays on its own engine, through helpers too. - for (const start of files.filter(globeEngine)) { - const navara = start.startsWith('src/globe/navara/'); + for (const owner of ['cesium', 'navara']) { + const navara = owner === 'navara'; const label = navara ? 'Navara globe engine' : 'Cesium globe engine'; const foreignPackage = navara ? cesiumPackage : navaraPackage; const foreignEngine = navara ? 'src/globe/cesium/' : 'src/globe/navara/'; @@ -342,7 +322,10 @@ export function checkImportDirections(root) { else visit(to); } }; - visit(start); + for (const start of files.filter((file) => + file.startsWith(`src/globe/${owner}/`), + )) + visit(start); } // Common voice controls may use their DOM view, but never a protocol implementation. const voiceSeen = new Set(); diff --git a/scripts/module-analysis.mjs b/scripts/module-analysis.mjs index 9871e5f5b..0bcf31488 100644 --- a/scripts/module-analysis.mjs +++ b/scripts/module-analysis.mjs @@ -1,4 +1,5 @@ import { parsers } from 'prettier/plugins/babel.mjs'; +import { vueScripts } from './vue-scripts.mjs'; const browserGlobals = new Set([ 'window', @@ -19,9 +20,10 @@ const browserGlobals = new Set([ * Inspect literal module dependencies and browser-platform references without * executing code. With `dynamic`, also list the import() specifiers apart. */ -export function analyzeModule(source, { dynamic = false } = {}) { +export function analyzeModule(source, { dynamic = false, filename = '' } = {}) { const imports = []; const dynamicImports = []; + const staticImports = []; const browser = new Set(); function walk(node, parent, field) { if (!node || typeof node !== 'object') return; @@ -41,6 +43,7 @@ export function analyzeModule(source, { dynamic = false } = {}) { imports.push(node.source.value); if (node.type === 'ImportExpression') dynamicImports.push(node.source.value); + else staticImports.push(node.source.value); } if (node.type === 'CallExpression' && node.callee?.name === 'require') throw new Error('Runtime modules must use ES imports'); @@ -76,11 +79,14 @@ export function analyzeModule(source, { dynamic = false } = {}) { else if (value && typeof value === 'object') walk(value, node, key); } } - walk(parsers.babel.parse(source)); + for (const script of filename.endsWith('.vue') + ? vueScripts(source, filename) + : [source]) + walk(parsers.babel.parse(script)); return { imports, browser: [...browser], - ...(dynamic && { dynamicImports }), + ...(dynamic && { dynamicImports, staticImports }), }; } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 40f59a2ea..f5127df16 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2259,6 +2259,7 @@ "src/layers/shared/selectionKeeper.js", "src/layers/shared/color.js", "src/layers/submarineCables/render/display.js", + "src/layers/shared/render/surfacePoints.js", "src/overlays/bindOverlayHost.js" ], "external": ["cesium"] @@ -3595,6 +3596,7 @@ "src/layers/earthquakes/render/display.js", "src/layers/airQuality/render/display.js", "src/layers/submarineCables/render/display.js", + "src/layers/shared/render/surfacePoints.js", "src/layers/firms/render/sprites.js", "src/layers/cyclones/render/display.js", "src/layers/droneAirspace/render/display.js", @@ -4352,6 +4354,7 @@ "src/layers/gnss/render/display.js", "src/layers/firms/render/sprites.js", "src/layers/submarineCables/render/display.js", + "src/layers/shared/render/surfacePoints.js", "src/layers/perimeters/render/display.js", "src/layers/droneAirspace/render/display.js", "src/globe/ground.js", @@ -4539,6 +4542,7 @@ "ui-share-restoration": { "exports": ["./ui/share-restoration"], "modules": [ + "src/ui/layerNames.js", "src/i18n/marker.js", "src/data/installationFeedback.js", "src/data/idSwitches.js", diff --git a/scripts/qa-cables-overlay.mjs b/scripts/qa-cables-overlay.mjs index 6627e0ed5..e5f0cf834 100644 --- a/scripts/qa-cables-overlay.mjs +++ b/scripts/qa-cables-overlay.mjs @@ -111,7 +111,7 @@ try { // paths in one engine.ground corridor set, landing points and reference // tips in mover sets, stems in engine.lines, labels on the world-overlay // host. It owns no Cesium DataSource or Entity any more. - const counts = await page.evaluate((layerId) => { + const counts = await page.evaluate((layerId, legacy) => { const gev = window.__godsEyeView; const v = gev.viewer; const engine = gev.engine; @@ -126,6 +126,7 @@ try { out.layerDataSources++; out.layerEntities += ds.entities.values.length; } + if (legacy) return out; for (const set of engine.ground.list()) { if (set.id !== `${layerId}-paths`) continue; out.pathSets++; @@ -146,7 +147,7 @@ try { const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {}; out.referenceLabelCount = stats.referenceLabelCount ?? null; return out; - }, LAYER_ID); + }, LAYER_ID, legacy); report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts); // ── a. per-frame scene.render cost over a ~10 s driven orbit ───────── diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index aae57d47f..712d8c865 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -1485,8 +1485,9 @@ async function main() { const scene = viewer.scene; const canvas = scene.canvas; const rect = canvas.getBoundingClientRect(); - const { hitTestWorldOverlay } = - (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); + const { hitTest } = ( + await import('/src/overlays/worldOverlay.js') + ).worldOverlayFor(window.__godsEyeView.engine); const candidates = [ [0.55, 0.72], [0.45, 0.72], @@ -1508,7 +1509,7 @@ async function main() { const x = Math.round(canvas.clientWidth * fx); const y = Math.round(canvas.clientHeight * fy); const picked = scene.pick({ x, y }); - const card = hitTestWorldOverlay(x, y, { sourceId: 'cctv' }); + const card = hitTest(x, y, { sourceId: 'cctv' }); const top = document.elementFromPoint(rect.left + x, rect.top + y); if ( resolveId(picked) === null && diff --git a/scripts/qa-directions.mjs b/scripts/qa-directions.mjs index de8f092d9..1bc9886c3 100644 --- a/scripts/qa-directions.mjs +++ b/scripts/qa-directions.mjs @@ -36,8 +36,7 @@ const check = (name, passed, detail = '') => { const browser = await puppeteer.launch({ headless: process.env.QA_HEADFUL === '1' ? false : true, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--no-sandbox', '--disable-background-timer-throttling', diff --git a/scripts/qa-engine-switch.mjs b/scripts/qa-engine-switch.mjs index 2e60003fe..1f1451859 100644 --- a/scripts/qa-engine-switch.mjs +++ b/scripts/qa-engine-switch.mjs @@ -65,7 +65,9 @@ await strict.run({}, async (browser) => { (kind) => window.__godsEyeView?.engine?.kind === kind && document.querySelector('#globe-engine')?.value === kind && - !document.querySelector('#globe-engine').disabled && + !document.querySelector( + '#globe-engine:is([disabled], [aria-busy="true"])', + ) && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 90000 }, kind, diff --git a/scripts/qa-fire-camera-switch.mjs b/scripts/qa-fire-camera-switch.mjs index ba8f9379d..8bc4f2ebe 100644 --- a/scripts/qa-fire-camera-switch.mjs +++ b/scripts/qa-fire-camera-switch.mjs @@ -2,7 +2,12 @@ import { assertKeylessPreview } from './qa-strict-harness.mjs'; import assert from 'node:assert/strict'; import { installAlprFixture, cameras } from './fixtures/layers/alpr.mjs'; import { firmsFixture, FIRMS_VALID_COUNT } from './fixtures/layers/firms.mjs'; -import { createStrict, localPreview } from './qa-strict-harness.mjs'; +import { + clickEmptyGround, + createStrict, + localPreview, + waitForCameraAtRest, +} from './qa-strict-harness.mjs'; const url = localPreview(process.argv[2]), fire = process.argv.includes('--fires'), id = fire ? 'local-firms' : 'alpr-cameras'; @@ -113,36 +118,14 @@ await strict.run({}, async (b) => { ); // A click on empty ground (nothing picked there, nothing covering it). const clickEmpty = async () => { - const handle = await p.waitForFunction( - () => { - const { engine } = window.__godsEyeView; - const canvas = engine.host.canvas; - const box = canvas.getBoundingClientRect(); - for (let y = 240; y < 600; y += 9) - for (let x = 440; x < 840; x += 9) { - if ( - !canvas.contains( - document.elementFromPoint(box.left + x, box.top + y), - ) - ) - continue; - let free = true; - for (const [dx, dy] of [ - [0, 0], - [6, 0], - [-6, 0], - [0, 6], - [0, -6], - ]) - if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false; - if (free) return { x: box.left + x, y: box.top + y }; - } - return false; - }, - { timeout: 15000 }, - ); - const at = await handle.jsonValue(); - await p.mouse.click(at.x, at.y); + await clickEmptyGround(p, { + left: 440, + top: 240, + right: 840, + bottom: 600, + step: 9, + radius: 6, + }); await p.waitForFunction( (fire) => fire @@ -165,6 +148,10 @@ await strict.run({}, async (b) => { xy, ); await new Promise((r) => setTimeout(r, 1600)); + // The selection flies the camera to the record (1.9 s, longer on a busy + // machine); a switch before it lands opens the other engine on a view + // between the two, where the record can be off screen. + await waitForCameraAtRest(p); const expectedId = fire ? await p.evaluate(() => window.qaGround.getSelectedInfo().id) : xy.id; diff --git a/scripts/qa-gbfs-names.mjs b/scripts/qa-gbfs-names.mjs index 40aa9425b..610fe1107 100644 --- a/scripts/qa-gbfs-names.mjs +++ b/scripts/qa-gbfs-names.mjs @@ -9,6 +9,7 @@ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; import sharp from 'sharp'; +import { fromDegrees } from '../src/geo/cartesian3.js'; import { watchBrowserPage, redact } from './qa-browserEvidence.mjs'; const urlIndex = process.argv.indexOf('--url'); @@ -150,23 +151,25 @@ async function open(mode = 'normal') { '0'), { timeout: 60000 }, ); - await page.evaluate(async () => { - const { dataManager, engine } = window.__godsEyeView; - await window.__godsEyeView.styleManager.initialRestorePromise; - window.__godsEyeView.styleManager.orbitController.stop(); - for (const [id, layer] of dataManager.layers) { - if (layer.enabled) - await dataManager.setEnabled(id, false, { origin: 'user' }); - } - const geo = await import('/src/geo/index.js'); - engine.camera.cancelFlight(); - engine.camera.setView({ - destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - await dataManager.setEnabled('bikeshare', true, { origin: 'user' }); - window.__godsEyeView.requestRender('gbfs-name-qa'); - }); + await page.evaluate( + async (destination) => { + const { dataManager, engine } = window.__godsEyeView; + await window.__godsEyeView.styleManager.initialRestorePromise; + window.__godsEyeView.styleManager.orbitController.stop(); + for (const [id, layer] of dataManager.layers) { + if (layer.enabled) + await dataManager.setEnabled(id, false, { origin: 'user' }); + } + engine.camera.cancelFlight(); + engine.camera.setView({ + destination, + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + await dataManager.setEnabled('bikeshare', true, { origin: 'user' }); + window.__godsEyeView.requestRender('gbfs-name-qa'); + }, + fromDegrees(-97.7449, 30.2701, 3000), + ); return { page, errors }; } @@ -220,15 +223,17 @@ async function w2Checks(page) { ['close', 3000], ['global', 100000], ]) { - await page.evaluate(async (height) => { - const app = window.__godsEyeView; - await app.dataManager.setEnabled('bikeshare', false); - const geo = await import('/src/geo/index.js'); - app.engine.camera.setView({ - destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, height), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - }, height); + await page.evaluate( + async (destination) => { + const app = window.__godsEyeView; + await app.dataManager.setEnabled('bikeshare', false); + app.engine.camera.setView({ + destination, + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + }, + fromDegrees(-97.7449, 30.2701, height), + ); await settledTiles(page); await nextFrame(page); const off = await page.screenshot({ @@ -304,15 +309,17 @@ async function w2Checks(page) { for (const element of document.querySelectorAll('.gbfs-raster-hidden')) element.classList.remove('gbfs-raster-hidden'); }); - await page.evaluate(async () => { - const app = window.__godsEyeView; - const geo = await import('/src/geo/index.js'); - app.engine.camera.setView({ - destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - await app.dataManager.setEnabled('bikeshare', false); - }); + await page.evaluate( + async (destination) => { + const app = window.__godsEyeView; + app.engine.camera.setView({ + destination, + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + await app.dataManager.setEnabled('bikeshare', false); + }, + fromDegrees(-97.7449, 30.2701, 3000), + ); for (let i = 0; i < 10; i++) { await page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('bikeshare', true), @@ -520,22 +527,24 @@ try { ); } await page.setViewport({ width: 390, height: 844 }); - await page.evaluate(async () => { - window.__qaPainted = []; - const app = window.__godsEyeView; - // Use the supported clean view to inspect the mobile card clear of panels. - app.styleManager.toggleCleanView(true); - const geo = await import('/src/geo/index.js'); - app.engine.camera.cancelFlight(); - app.engine.camera.setView({ - destination: geo.cartesian3.fromDegrees(-97.7449, 30.2701, 3000), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - app.dataManager.layers - .get('bikeshare') - .module._selectBikeshareStationForTest('austin-capmetro:english'); - app.requestRender('gbfs-name-mobile-qa'); - }); + await page.evaluate( + async (destination) => { + window.__qaPainted = []; + const app = window.__godsEyeView; + // Use the supported clean view to inspect the mobile card clear of panels. + app.styleManager.toggleCleanView(true); + app.engine.camera.cancelFlight(); + app.engine.camera.setView({ + destination, + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + app.dataManager.layers + .get('bikeshare') + .module._selectBikeshareStationForTest('austin-capmetro:english'); + app.requestRender('gbfs-name-mobile-qa'); + }, + fromDegrees(-97.7449, 30.2701, 3000), + ); await page.waitForFunction( () => window.__qaPainted.includes('QA English Square'), { timeout: 10000 }, diff --git a/scripts/qa-ground-layer-switch.mjs b/scripts/qa-ground-layer-switch.mjs index af368358f..1dbeeac97 100644 --- a/scripts/qa-ground-layer-switch.mjs +++ b/scripts/qa-ground-layer-switch.mjs @@ -2,9 +2,11 @@ import { fromDegrees } from '../src/geo/cartesian3.js'; import sharp from 'sharp'; import assert from 'node:assert/strict'; import { + clickEmptyGround, createStrict, localPreview, openPage, + waitForCameraAtRest, waitForEngine, } from './qa-strict-harness.mjs'; @@ -400,39 +402,19 @@ await strict.run({}, async (b) => { // Click-picking on the running engine: clear the selection with a click on // empty water, then select the vessel with a click on its chevron. const pickVessel = async () => { + // The last selection flies the camera to the vessel, and a click does + // not cut that flight short: click once it has landed. + await waitForCameraAtRest(p); // Empty ground: a point in the middle of the view where the engine // picks nothing and nothing covers the canvas. - const empty = await p - .waitForFunction( - () => { - const { engine } = window.__godsEyeView; - const canvas = engine.host.canvas; - const box = canvas.getBoundingClientRect(); - for (let y = 220; y < 560; y += 11) - for (let x = 300; x < 720; x += 11) { - if ( - !canvas.contains( - document.elementFromPoint(box.left + x, box.top + y), - ) - ) - continue; - let free = true; - for (const [dx, dy] of [ - [0, 0], - [10, 0], - [-10, 0], - [0, 10], - [0, -10], - ]) - if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false; - if (free) return { x: box.left + x, y: box.top + y }; - } - return false; - }, - { timeout: 15000 }, - ) - .then((handle) => handle.jsonValue()); - await p.mouse.click(empty.x, empty.y); + await clickEmptyGround(p, { + left: 300, + top: 220, + right: 720, + bottom: 560, + step: 11, + radius: 10, + }); await p.waitForFunction( () => !window.qaVesselEntry.module.getSelectedInfo(), ); @@ -447,6 +429,8 @@ await strict.run({}, async (b) => { 'ais-live-vessels', 'a click on the chevron selects the vessel', ); + // The next switch starts from the landed view. + await waitForCameraAtRest(p); }; await click(-79.38, 43.65); await p.waitForFunction( diff --git a/scripts/qa-installation-polish.mjs b/scripts/qa-installation-polish.mjs index d626ce8a8..0fbf308bf 100644 --- a/scripts/qa-installation-polish.mjs +++ b/scripts/qa-installation-polish.mjs @@ -10,8 +10,9 @@ export async function checkInstallationPan(page, dir) { ); await sleep(3000); await page.evaluate(async () => { - const { getOverlayPaintRect } = - (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); + const { getPaintRect } = ( + await import('/src/overlays/worldOverlay.js') + ).worldOverlayFor(window.__godsEyeView.engine); const g = window.__godsEyeView; const installations = g.dataManager.layers.get( 'military-installations', @@ -28,10 +29,10 @@ export async function checkInstallationPan(page, dir) { const sites = [], centers = []; installations.visitNamedMarkers((id) => { - if (getOverlayPaintRect('military-installations', id)) sites.push(id); + if (getPaintRect('military-installations', id)) sites.push(id); }); for (const id of centerIds) - if (getOverlayPaintRect('local-datacenters', id)) centers.push(id); + if (getPaintRect('local-datacenters', id)) centers.push(id); window.__polishPanFrames.push({ t: performance.now(), sites, centers }); }); }); @@ -187,8 +188,9 @@ export async function checkInstallationSelection( window.__polishSelectionFrames = []; const { getSelectedEntityContext } = await import('/src/data/contextStore.js'); - const { getOverlayPaintRect, getWorldOverlayDiagnostics } = - (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); + const { getPaintRect, getDiagnostics } = ( + await import('/src/overlays/worldOverlay.js') + ).worldOverlayFor(window.__godsEyeView.engine); // The selected site's fill is one engine.ground polygon set // (render/display.js SELECTION_FILL_SET_ID), an item per footprint // part; the layer owns no Cesium entities or ground primitives. @@ -225,17 +227,13 @@ export async function checkInstallationSelection( g.viewer.scene.postRender.addEventListener(() => { window.__polishSelectionFrames.push({ paints: window.__polishTitlePaints.splice(0).length, - own: Boolean( - getOverlayPaintRect('military-installations', record.id), - ), + own: Boolean(getPaintRect('military-installations', record.id)), tracked: Boolean( - getOverlayPaintRect('tracked', `installations:${record.id}`), + getPaintRect('tracked', `installations:${record.id}`), ), selected: getSelectedEntityContext()?.id === record.id, total: - getWorldOverlayDiagnostics().paintedBySource[ - 'military-installations' - ] || 0, + getDiagnostics().paintedBySource['military-installations'] || 0, }); }); return { diff --git a/scripts/qa-label-readability.mjs b/scripts/qa-label-readability.mjs index 0d22a7393..23a8c0807 100644 --- a/scripts/qa-label-readability.mjs +++ b/scripts/qa-label-readability.mjs @@ -7,8 +7,8 @@ * node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after [--engine=cesium|navara] * * Engine-neutral: camera, tiles and layers go through the globe engine and the - * QA probe; an engine without the flights/satellites layers (Navara today) - * shoots the scenes without a contact field and says so. + * QA probe; an engine without the flights/satellites layers shoots the + * scenes without a contact field and says so. * * Every scene pins its own camera pose AND substitutes a deterministic * synthetic contact field for the live pollers, so a `before` run and an diff --git a/scripts/qa-labels.mjs b/scripts/qa-labels.mjs index 84a51b470..332d16a80 100644 --- a/scripts/qa-labels.mjs +++ b/scripts/qa-labels.mjs @@ -7,8 +7,8 @@ * * Engine-neutral: the camera and frames go through the globe engine and the * layers through the QA probe (window.__godsEyeView.layers). On an engine - * without the flights, military and satellites layers (Navara today) the - * field checks are SKIPPED and the settings checks still run. + * without the flights, military and satellites layers the field checks are + * SKIPPED and the settings checks still run. * * The harness replaces only the three detection read APIs in the loaded page. * It does not enable their network pollers or mutate repository data. The field diff --git a/scripts/qa-layer-handoff.mjs b/scripts/qa-layer-handoff.mjs index 5f0e6aa76..77e9480a2 100644 --- a/scripts/qa-layer-handoff.mjs +++ b/scripts/qa-layer-handoff.mjs @@ -111,7 +111,9 @@ await strict.run({}, async (browser) => { page.waitForFunction( (engine) => window.__godsEyeView?.engine?.kind === engine && - !document.querySelector('#globe-engine')?.disabled && + !document.querySelector( + '#globe-engine:is([disabled], [aria-busy="true"])', + ) && document.querySelector('#loading-screen.hidden') && !document.querySelector('.engine-switch-frame'), { timeout: 90000 }, diff --git a/scripts/qa-military-names.mjs b/scripts/qa-military-names.mjs index 04584ca7d..fa84bbdf7 100644 --- a/scripts/qa-military-names.mjs +++ b/scripts/qa-military-names.mjs @@ -152,14 +152,15 @@ try { await settle(); const before = await stats(); const overlaps = await page.evaluate(async () => { - const { getOverlayPaintRect } = - (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); + const { getPaintRect } = ( + await import('/src/overlays/worldOverlay.js') + ).worldOverlayFor(window.__godsEyeView.engine); const layer = window.__godsEyeView.dataManager.layers.get( 'military-installations', ).module; const rects = []; layer.visitNamedMarkers((id) => { - const rect = getOverlayPaintRect('military-installations', id); + const rect = getPaintRect('military-installations', id); if (rect) rects.push(rect); }); let overlaps = 0; @@ -223,9 +224,7 @@ try { opacityProperty: true, }), labels: - host.getWorldOverlayDiagnostics().paintedBySource[ - 'military-installations' - ] || 0, + host.getDiagnostics().paintedBySource['military-installations'] || 0, }; }); assert.ok( @@ -244,10 +243,10 @@ try { ), ); result.disabled = await page.evaluate(async () => { - const host = (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); - return host.getWorldOverlayDiagnostics().entriesBySource[ - 'military-installations' - ]; + const host = ( + await import('/src/overlays/worldOverlay.js') + ).worldOverlayFor(window.__godsEyeView.engine); + return host.getDiagnostics().entriesBySource['military-installations']; }); assert.equal(result.disabled, 0); await page.evaluate(() => @@ -266,8 +265,9 @@ try { } await page.evaluate(async () => { const C = await import('/node_modules/cesium/Build/Cesium/index.js'); - const { getOverlayPaintRect } = - (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine); + const { getPaintRect } = ( + await import('/src/overlays/worldOverlay.js') + ).worldOverlayFor(window.__godsEyeView.engine); const g = window.__godsEyeView; const layer = g.dataManager.layers.get('military-installations').module; const overlay = document.createElement('div'); @@ -284,7 +284,7 @@ try { labels = [], rects = []; layer.visitNamedMarkers((id, point) => { - const rect = getOverlayPaintRect('military-installations', id); + const rect = getPaintRect('military-installations', id); if (rect) rects.push(rect); const p = C.SceneTransforms.worldToWindowCoordinates( g.viewer.scene, diff --git a/scripts/qa-navara-draw.mjs b/scripts/qa-navara-draw.mjs index 8da65b128..84615d832 100644 --- a/scripts/qa-navara-draw.mjs +++ b/scripts/qa-navara-draw.mjs @@ -48,7 +48,9 @@ await strict.run( (k) => window.__godsEyeView?.engine?.kind === k && window.__gevDrawTool?.attached && - !document.querySelector('#globe-engine')?.disabled && + !document.querySelector( + '#globe-engine:is([disabled], [aria-busy="true"])', + ) && !document.querySelector('.engine-switch-frame') && document.querySelector('#loading-screen.hidden'), { timeout: 120000 }, diff --git a/scripts/qa-navara-geo-import.mjs b/scripts/qa-navara-geo-import.mjs index 84a919d30..728a09b42 100644 --- a/scripts/qa-navara-geo-import.mjs +++ b/scripts/qa-navara-geo-import.mjs @@ -2,8 +2,8 @@ // UTILITIES ▸ Import on Navara under the strict network policy: GeoJSON, // KML, KMZ (a packed icon and a ground overlay) and GPX through the real // file input, each one row; Navara draws their shapes in the row colour and -// their pins, counts what it cannot draw (the KMZ ground overlay) and says -// so in the hint; hide, fly-to and remove work on the engine; the files +// their pins and packed ground overlay; unsupported content is counted +// in the hint; hide, fly-to and remove work on the engine; the files // survive a switch to Cesium and back and draw on each engine. No file makes // the page request anything it names (the strict policy fails any request // outside the preview and the free tile cache). @@ -152,7 +152,9 @@ await strict.run( (k) => window.__godsEyeView?.engine?.kind === k && window.__gevGeoImport?.diagnostics().attached && - !document.querySelector('#globe-engine')?.disabled && + !document.querySelector( + '#globe-engine:is([disabled], [aria-busy="true"])', + ) && !document.querySelector('.engine-switch-frame') && document.querySelector('#loading-screen.hidden'), { timeout: 120000 }, @@ -171,7 +173,7 @@ await strict.run( rows: document.querySelectorAll('#geo-import-list .geo-import-row') .length, hint: document.getElementById('geo-import-hint')?.textContent || '', - // Navara draws the pins as mover sets; Cesium as entities (none). + // Both engines draw imported pins through mover sets. pins: window.__godsEyeView.engine.movers .list() .filter(({ id }) => id.startsWith('geo-import-')) diff --git a/scripts/qa-overlay-baseline.mjs b/scripts/qa-overlay-baseline.mjs index 6a5fbf47f..719bfb09c 100644 --- a/scripts/qa-overlay-baseline.mjs +++ b/scripts/qa-overlay-baseline.mjs @@ -18,7 +18,7 @@ * node scripts/qa-overlay-baseline.mjs --engine=navara * * Engine-neutral: the camera and frames go through the globe engine and the - * layers through the QA probe (window.__godsEyeView.layers, dev server only). + * layers through the public engine and data manager surfaces. * A scene whose layers the engine does not have is SKIPPED. The inventory * counts the globe contract's sets (movers, lines, ground, imagery) on both * engines; on Cesium it also counts what is left of Entities, LabelGraphics @@ -29,6 +29,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { fromDegrees } from '../src/geo/cartesian3.js'; const CHROME_EXECUTABLE = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; const DEFAULT_URL = 'http://localhost:4176'; @@ -362,7 +363,7 @@ async function installPageInstrumentation(page) { async function waitForApp(page) { await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.waitForFunction( - () => Boolean(window.__godsEyeView?.layers && window.__godsEyeView?.dataManager), + () => Boolean(window.__godsEyeView?.engine && window.__godsEyeView?.dataManager), { timeout: 60_000 }, ); await page.evaluate(() => { @@ -375,17 +376,16 @@ async function waitForApp(page) { async function setCamera(page, camera) { if (!camera) return; - await page.evaluate(async ([lon, lat, height, heading, pitch]) => { + await page.evaluate(([destination, heading, pitch]) => { const { engine, viewer } = window.__godsEyeView; - const geo = await import('/src/geo/index.js'); engine.camera.cancelFlight(); viewer?.scene.tweens?.removeAll?.(); engine.camera.setView({ - destination: geo.cartesian3.fromDegrees(lon, lat, height), + destination, orientation: { heading, pitch, roll: 0 }, }); engine.render.request('qa-overlay-baseline'); - }, camera); + }, [fromDegrees(...camera.slice(0, 3)), ...camera.slice(3)]); } async function readLayerState(page, layerId) { @@ -646,7 +646,7 @@ async function prepareTrackedFlight(page, cockpit) { }); if (!tracked.ok) return `Civil tracking unavailable (${tracked.reason})`; try { - await page.waitForFunction(() => Boolean(window.__godsEyeView.layers.following()), { timeout: 10_000 }); + await page.waitForFunction(() => Boolean(window.__godsEyeView.engine.camera.following()), { timeout: 10_000 }); } catch { return 'Civil tracking did not create a tracked entity'; } @@ -679,7 +679,7 @@ async function prepareMission(page) { }); if (!selected) return 'Mission roster did not expose a selectable mission'; try { - await page.waitForFunction(() => Boolean(window.__godsEyeView.layers.drawn('rocket-launches')?.selected), { timeout: 10_000 }); + await page.waitForFunction(() => Boolean(window.__godsEyeView.dataManager.layers.get('rocket-launches')?.module?.qaDrawn?.()?.selected), { timeout: 10_000 }); } catch { return 'Mission selection did not reach the layer probe'; } @@ -689,7 +689,7 @@ async function prepareMission(page) { async function prepareScene(page, scene) { const missing = await page.evaluate( - (ids) => ids.filter((id) => !window.__godsEyeView.layers.available(id)), + (ids) => ids.filter((id) => !window.__godsEyeView.dataManager.layers.has(id)), scene.layers, ); if (missing.length) diff --git a/scripts/qa-perf.mjs b/scripts/qa-perf.mjs index d41413a99..0a7730943 100644 --- a/scripts/qa-perf.mjs +++ b/scripts/qa-perf.mjs @@ -71,7 +71,7 @@ * Usage: node scripts/qa-perf.mjs [--url http://localhost:4173] [--engine=cesium|navara] * Engine-neutral: the camera, frames and layers go through the globe engine * and the QA probe (window.__godsEyeView.layers); on an engine without a - * layer (Navara today) that layer's checks are SKIPPED, not failed. + * layer that layer's checks are SKIPPED, not failed. * Requires a running dev server. Headless; flags disable occlusion * throttling so rAF cadence is trustworthy (hidden-pane gotcha). */ @@ -338,14 +338,16 @@ try { await page.evaluate(() => { window.__godsEyeView.styleManager.setStyle('retro'); }); await new Promise((r) => setTimeout(r, 900)); // crossfade + first ticks const dAnim = await diag(); - // The animated styles are Cesium post-process stages; Navara draws its own - // looks and has no style animation (a known gap, not a regression). + // This assertion checks the Cesium style-anim render hold. if (engineKind === 'cesium') { check('animated style takes the style-anim hold (continuous)', dAnim?.mode === 'continuous' && dAnim.holds.includes('style-anim'), dAnim); const animFrames = await countFrames(2_000); check('animated style keeps frames flowing (≥50% rAF)', animFrames.renders >= animFrames.rafs * 0.5, animFrames); } else { - skip('animated style takes the style-anim hold and keeps frames flowing', `the visual styles are Cesium post stages; ${engineKind} has none`); + skip( + 'animated style takes the style-anim hold and keeps frames flowing', + `this check measures the Cesium style-anim hold, not ${engineKind} animation`, + ); } // Returning to normal keeps detection ON by design (it is the default, and // detection persists across style switches). Since 2026-08-22 detection holds diff --git a/scripts/qa-strict-context-loss.mjs b/scripts/qa-strict-context-loss.mjs index 64a9c9dff..60c530c6d 100644 --- a/scripts/qa-strict-context-loss.mjs +++ b/scripts/qa-strict-context-loss.mjs @@ -67,7 +67,9 @@ await strict.run({}, async (b) => { await p.waitForFunction( () => (window.__godsEyeView && - !document.querySelector('#globe-engine')?.disabled && + !document.querySelector( + '#globe-engine:is([disabled], [aria-busy="true"])', + ) && !document.querySelector('.engine-switch-frame') && document.querySelector('#loading-screen.hidden')) || document.querySelector('.engine-switch-retry'), diff --git a/scripts/qa-strict-failure-matrix.mjs b/scripts/qa-strict-failure-matrix.mjs index e70ed6fa3..e97ed491d 100644 --- a/scripts/qa-strict-failure-matrix.mjs +++ b/scripts/qa-strict-failure-matrix.mjs @@ -229,7 +229,9 @@ await strict.run({}, async (b) => { window.__qaSlow.at && !window.__qaSlow.coverGone && !document.querySelector('.engine-switch-frame') && - !document.querySelector('#globe-engine')?.disabled + !document.querySelector( + '#globe-engine:is([disabled], [aria-busy="true"])', + ) ) { window.__qaSlow.coverGone = performance.now(); observer.disconnect(); @@ -245,7 +247,9 @@ await strict.run({}, async (b) => { await p.waitForFunction( (target) => window.__godsEyeView?.engine?.kind === target && - !document.querySelector('#globe-engine')?.disabled && + !document.querySelector( + '#globe-engine:is([disabled], [aria-busy="true"])', + ) && !document.querySelector('.engine-switch-frame'), { polling: 200, timeout: 60000 }, target, diff --git a/scripts/qa-strict-harness.mjs b/scripts/qa-strict-harness.mjs index f88692f88..3a6d6100c 100644 --- a/scripts/qa-strict-harness.mjs +++ b/scripts/qa-strict-harness.mjs @@ -440,10 +440,11 @@ export async function pickEngine( ?.getAttribute('aria-expanded') === 'true', ); } - await page.waitForSelector('#globe-engine:not([disabled])', { - visible: true, - timeout: 90000, - }); + // The picker stays focusable while busy: aria-busy marks a switch. + await page.waitForSelector( + '#globe-engine:not([disabled]):not([aria-busy="true"])', + { visible: true, timeout: 90000 }, + ); const from = await page.$eval('#globe-engine', (el) => el.value); assert.notEqual(from, kind, `the picker already shows ${kind}`); // The key typed below is `kind`'s first letter: it selects the next option @@ -479,14 +480,16 @@ export async function pickEngine( ); window.__qaPickObserver?.disconnect(); window.__qaPickObserver = new MutationObserver(() => { - const disabled = document.querySelector('#globe-engine')?.disabled; + const disabled = document.querySelector( + '#globe-engine:is([disabled], [aria-busy="true"])', + ); if (disabled) window.__qaPick.busy = true; else if (window.__qaPick.busy && window.__qaPick.idleAt == null) window.__qaPick.idleAt = performance.now(); }); window.__qaPickObserver.observe(document.body, { attributes: true, - attributeFilter: ['disabled'], + attributeFilter: ['disabled', 'aria-busy'], subtree: true, }); const describe = (hit) => @@ -523,8 +526,10 @@ export async function pickEngine( // Whatever the person does next, right after the key (no round trip to // the page in between). if (afterInput) await afterInput(); + // The change starts a cold switch at once: Cesium.js then runs as one + // task, seconds long under CPU throttling, before the page answers. await page.waitForFunction(() => window.__qaPick?.changed, { - timeout: 5000, + timeout: 30000, polling: 50, }); // Headless Chrome opens no menu for a click on the select: the key @@ -538,7 +543,9 @@ export async function pickEngine( await page.waitForFunction( () => (window.__qaPick.busy && - !document.querySelector('#globe-engine')?.disabled && + !document.querySelector( + '#globe-engine:is([disabled], [aria-busy="true"])', + ) && document.querySelector('#loading-screen.hidden') && !document.querySelector('.engine-switch-frame')) || document.querySelector('.engine-switch-retry'), @@ -572,7 +579,9 @@ export async function waitForEngine(page, kind, { timeout = 120000 } = {}) { window.__godsEyeView?.engine?.kind === (expected === 'cesium' ? 'cesium' : 'navara') && document.querySelector('#globe-engine')?.value === expected && - !document.querySelector('#globe-engine')?.disabled && + !document.querySelector( + '#globe-engine:is([disabled], [aria-busy="true"])', + ) && !document.querySelector('.engine-switch-frame') && document.querySelector('#loading-screen.hidden'), { timeout }, @@ -837,3 +846,65 @@ export function createStrict({ }; return strict; } + +/** Click uncovered canvas where the engine picks nothing within the radius. */ +export async function clickEmptyGround( + page, + { left, top, right, bottom, step = 9, radius = 6 }, +) { + const handle = await page.waitForFunction( + ({ left, top, right, bottom, step, radius }) => { + const { engine } = window.__godsEyeView; + const canvas = engine.host.canvas; + const box = canvas.getBoundingClientRect(); + for (let y = top; y < bottom; y += step) + for (let x = left; x < right; x += step) { + if ( + !canvas.contains( + document.elementFromPoint(box.left + x, box.top + y), + ) + ) + continue; + const free = [ + [0, 0], + [radius, 0], + [-radius, 0], + [0, radius], + [0, -radius], + ].every(([dx, dy]) => !engine.picker.pick({ x: x + dx, y: y + dy })); + if (free) return { x: box.left + x, y: box.top + y }; + } + return false; + }, + { timeout: 15000 }, + { left, top, right, bottom, step, radius }, + ); + try { + const at = await handle.jsonValue(); + await page.mouse.click(at.x, at.y); + } finally { + await handle.dispose(); + } +} + +/** + * Wait until no flight holds the camera and it stays still. A selection's + * camera transfer is a flight; on both engines a flight turns the camera + * inputs off, and a click does not cut it short. + */ +export async function waitForCameraAtRest(page, { timeout = 15000 } = {}) { + await page.evaluate(() => { + window.__qaRestPose = null; + }); + await page.waitForFunction( + () => { + const { engine } = window.__godsEyeView; + const { x, y, z } = engine.camera.pose().position; + const pose = [x, y, z].map((value) => value.toFixed(2)).join(); + const still = window.__qaRestPose === pose; + window.__qaRestPose = pose; + return still && engine.input.cameraInputsEnabled(); + }, + { timeout, polling: 100 }, + ); +} diff --git a/scripts/qa-strict-hidden-switch.mjs b/scripts/qa-strict-hidden-switch.mjs index deef7ca24..1d2b9ce64 100644 --- a/scripts/qa-strict-hidden-switch.mjs +++ b/scripts/qa-strict-hidden-switch.mjs @@ -52,7 +52,11 @@ await strict.run({}, async (b) => { window.__qaHidden.shown.push({ state: document.visibilityState, at: performance.now(), - busy: !!document.querySelector('#globe-engine')?.disabled, + busy: Boolean( + document.querySelector( + '#globe-engine:is([disabled], [aria-busy="true"])', + ), + ), }), ); }, kind); @@ -69,7 +73,9 @@ await strict.run({}, async (b) => { await p.waitForFunction( (kind) => window.__godsEyeView?.engine?.kind === kind && - !document.querySelector('#globe-engine')?.disabled && + !document.querySelector( + '#globe-engine:is([disabled], [aria-busy="true"])', + ) && !document.querySelector('.engine-switch-frame') && document.querySelector('#loading-screen.hidden'), { polling: 250, timeout: 60000 }, diff --git a/scripts/qa-strict-parked-release.mjs b/scripts/qa-strict-parked-release.mjs index 1a3e54cec..db44ada6b 100644 --- a/scripts/qa-strict-parked-release.mjs +++ b/scripts/qa-strict-parked-release.mjs @@ -1,4 +1,4 @@ -// Contract: a parked globe is released after ?parkedMs= (3 s here; three +// Contract: a parked globe is released after ?parkedMs= (8 s here; three // minutes by default). A switch back in time is warm (the same engine // object); after the delay the parked engine's WebGL context is lost and a // switch back builds a new engine on the same session that draws a @@ -21,7 +21,8 @@ import { startSession, } from './qa-strict-session.mjs'; -const PARKED_MS = 3000; +// Room for a cold switch on a busy machine (2-3 s) before the switch back. +const PARKED_MS = 8000; const base = localPreview(process.argv[2]); const other = (kind) => (kind === 'navara' ? 'cesium' : 'navara'); const strict = createStrict({ origin: base.origin, layers: [] }); @@ -41,8 +42,9 @@ await strict.run({}, async (b) => { console.log('PASS', name); }; const pick = async (kind) => { - await strict.pick(p, kind); + const { at } = await strict.pick(p, kind); await waitForEngine(p, kind); + return at; }; // The running engine is the one remembered under its kind (warm), and // the context of `kind`'s remembered engine is lost or not. @@ -77,17 +79,17 @@ await strict.run({}, async (b) => { await pick(parked); await rememberEngine(p); } - await pick(running); + const parkedAt = await pick(running); await rememberEngine(p); // A switch back in time is warm. - await wait(PARKED_MS / 3); - await pick(parked); + await wait(1000); + const backAt = await pick(parked); const early = await engines(parked); await assertSession(p, parked, `a quick switch back to ${parked}`); contract( `a switch back to ${parked} within the delay reuses the parked engine`, early.warm && early.lost === false, - early, + { ...early, parkedMs: Math.round(backAt - parkedAt) }, ); // Parked past the delay: released, and the next switch back is cold. await pick(running); diff --git a/scripts/qa-strict-rapid-switch.mjs b/scripts/qa-strict-rapid-switch.mjs index fedc95c7d..aa10158e9 100644 --- a/scripts/qa-strict-rapid-switch.mjs +++ b/scripts/qa-strict-rapid-switch.mjs @@ -89,7 +89,11 @@ await strict.run({}, async (b) => { window.__qaSteps.push({ step, engine: window.__godsEyeView?.engine?.kind ?? null, - busy: !!document.querySelector('#globe-engine')?.disabled, + busy: Boolean( + document.querySelector( + '#globe-engine:is([disabled], [aria-busy="true"])', + ), + ), }); window.__qaMark = mark; }); diff --git a/scripts/qa-strict-session.test.mjs b/scripts/qa-strict-session.test.mjs index 1522bbe53..9d6f5b2c4 100644 --- a/scripts/qa-strict-session.test.mjs +++ b/scripts/qa-strict-session.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { quantile, slope } from './qa-strict-session.mjs'; -import { strictArguments } from './qa-strict-harness.mjs'; +import { strictArguments, waitForCameraAtRest } from './qa-strict-harness.mjs'; test('slope is the least-squares growth per step', () => { assert.equal(slope([[0, 5]]), 0); @@ -45,3 +45,32 @@ test('the runner hands each scenario its layers and its manifest path', () => { evidence: process.env.QA_STRICT_EVIDENCE ?? null, }); }); + +test('the camera is at rest once a flight has landed and it stays still', async () => { + // A selection's flight holds the inputs off; once they are on, the camera + // still moves one more sample before it stays put. + const poses = [0, 400, 900, 900, 1250, 1250, 1250]; + const inputs = [false, false, false, false, true, true, true]; + let frame = 0; + const engine = { + camera: { pose: () => ({ position: { x: poses[frame], y: 0, z: 6.4e6 } }) }, + input: { cameraInputsEnabled: () => inputs[frame] }, + }; + const previous = globalThis.window; + globalThis.window = { __godsEyeView: { engine } }; + const page = { + evaluate: async (fn) => fn(), + async waitForFunction(fn) { + for (; frame < poses.length; frame++) if (fn()) return; + throw new Error('timed out'); + }, + }; + try { + await waitForCameraAtRest(page); + // Not while the flight holds the inputs (sample 3 repeats a pose), nor + // while the camera moves (sample 4). + assert.equal(frame, 5); + } finally { + globalThis.window = previous; + } +}); diff --git a/scripts/qa-traffic-motion.mjs b/scripts/qa-traffic-motion.mjs index 0d68d411b..6973d0d99 100644 --- a/scripts/qa-traffic-motion.mjs +++ b/scripts/qa-traffic-motion.mjs @@ -121,12 +121,14 @@ try { // place every frame; it never empties and refills a collection). // --baseline records the pre-contract build, whose PointPrimitive // collections sat in scene.primitives and were rebuilt by removeAll. - const engine = window.__godsEyeView.engine; - const createMoverSet = engine.movers.createSet.bind(engine.movers); - engine.movers.createSet = (options) => { - if (/^traffic-(fill|outline)$/.test(options?.id ?? '')) rebuilds++; - return createMoverSet(options); - }; + if (!baseline) { + const engine = window.__godsEyeView.engine; + const createMoverSet = engine.movers.createSet.bind(engine.movers); + engine.movers.createSet = (options) => { + if (/^traffic-(fill|outline)$/.test(options?.id ?? '')) rebuilds++; + return createMoverSet(options); + }; + } function discoverCollections() { if (!baseline) return; for (let i = 0; i < scene.primitives.length; i++) { diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs index 249165497..735194ad1 100644 --- a/scripts/qa-transit-browser.mjs +++ b/scripts/qa-transit-browser.mjs @@ -201,7 +201,7 @@ export async function sampleTransitPixels(page, palette) { ?.getDetectionDiagnostics?.() ?.calloutRects?.filter((r) => r.alpha > 0.01) || []; if (state._selectedKey) { - const card = overlay?.getOverlayPaintRect( + const card = overlay?.getPaintRect( 'transit-selected', state._selectedKey, ); diff --git a/scripts/qa-transit-heading.mjs b/scripts/qa-transit-heading.mjs index 208afad58..36530c829 100644 --- a/scripts/qa-transit-heading.mjs +++ b/scripts/qa-transit-heading.mjs @@ -8,12 +8,11 @@ export async function runTransitHeadingRegression(base, check) { base = transitQaBase(base); const browser = await puppeteer.launch({ headless: true, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--no-sandbox', '--disable-background-timer-throttling', - '--use-angle=metal', + ...(process.platform === 'darwin' ? ['--use-angle=metal'] : []), '--enable-gpu', ], }); diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index 3622dbb36..ff23998e7 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -11,10 +11,9 @@ const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery'; await mkdir(shots, { recursive: true }); const browser = await puppeteer.launch({ headless: true, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ - '--use-angle=metal', + ...(process.platform === 'darwin' ? ['--use-angle=metal'] : []), '--enable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index a9e28bac3..28aeee4b4 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -1657,7 +1657,7 @@ try { const t0 = performance.now(); const tick = () => { const entry = state._vehicles.get(key); - const rect = overlay.getOverlayPaintRect('transit-selected', key); + const rect = overlay.getPaintRect('transit-selected', key); if (entry?.marker) { const view = scene.camera.viewMatrix; const proj = scene.camera.frustum.projectionMatrix; diff --git a/scripts/qa-urban-engine-switch.mjs b/scripts/qa-urban-engine-switch.mjs index c9d749d60..7d0a5b8be 100644 --- a/scripts/qa-urban-engine-switch.mjs +++ b/scripts/qa-urban-engine-switch.mjs @@ -1,6 +1,12 @@ import assert from 'node:assert/strict'; import { fromDegrees } from '../src/geo/cartesian3.js'; -import { createStrict, localPreview, openPage, waitForEngine } from './qa-strict-harness.mjs'; +import { + clickEmptyGround, + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; import { installTransitFixture } from './fixtures/layers/transit.mjs'; import { combinedPayloads, @@ -128,36 +134,14 @@ await strict.run({}, async (b) => { ); // A click on empty ground (nothing picked around it, nothing covering it). const clickEmpty = async () => { - const handle = await p.waitForFunction( - () => { - const { engine } = window.__godsEyeView; - const canvas = engine.host.canvas; - const box = canvas.getBoundingClientRect(); - for (let y = 230; y < 600; y += 9) - for (let x = 440; x < 840; x += 9) { - if ( - !canvas.contains( - document.elementFromPoint(box.left + x, box.top + y), - ) - ) - continue; - let free = true; - for (const [dx, dy] of [ - [0, 0], - [8, 0], - [-8, 0], - [0, 8], - [0, -8], - ]) - if (engine.picker.pick({ x: x + dx, y: y + dy })) free = false; - if (free) return { x: box.left + x, y: box.top + y }; - } - return false; - }, - { timeout: 15000 }, - ); - const at = await handle.jsonValue(); - await p.mouse.click(at.x, at.y); + await clickEmptyGround(p, { + left: 440, + top: 230, + right: 840, + bottom: 600, + step: 9, + radius: 8, + }); await p.waitForFunction( () => !window.qaUrban diff --git a/scripts/qa-wildlife-switch.mjs b/scripts/qa-wildlife-switch.mjs index 8c1f8da5c..f423e1280 100644 --- a/scripts/qa-wildlife-switch.mjs +++ b/scripts/qa-wildlife-switch.mjs @@ -308,7 +308,11 @@ await strict.run({}, async (b) => { engine: window.__godsEyeView?.engine?.kind ?? null, picker: document.querySelector('#globe-engine')?.value ?? null, focus: document.activeElement?.id ?? null, - busy: document.querySelector('#globe-engine')?.disabled ?? null, + busy: Boolean( + document.querySelector( + '#globe-engine:is([disabled], [aria-busy="true"])', + ), + ), frame: Boolean(document.querySelector('.engine-switch-frame')), loading: document.querySelector('#loading-screen')?.className ?? null, })); @@ -343,6 +347,9 @@ await strict.run({}, async (b) => { let selected = null; for (const { kind, via, click: id } of LEGS) { if (via) { + const poseBefore = await p.evaluate(() => + window.__godsEyeView.engine.camera.pose(), + ); const reads = { ...fixture.asked }; const sighting = byId.get(selected); if (via === 'keyboard') { @@ -387,6 +394,29 @@ await strict.run({}, async (b) => { console.log('PASS', kind, 'picker click closed the card'); selected = null; } + const poseAfter = await p.evaluate(() => + window.__godsEyeView.engine.camera.pose(), + ); + const positionErrorM = Math.hypot( + ...['x', 'y', 'z'].map( + (axis) => poseAfter.position[axis] - poseBefore.position[axis], + ), + ); + const angleError = (key) => + Math.abs( + Math.atan2( + Math.sin(poseAfter[key] - poseBefore[key]), + Math.cos(poseAfter[key] - poseBefore[key]), + ), + ); + await strict.check( + p, + LAYER, + 'switch preserves the camera position and orientation', + positionErrorM < 1 && + ['heading', 'pitch', 'roll'].every((key) => angleError(key) < 1e-4), + { poseBefore, poseAfter, positionErrorM }, + ); await frame(); } diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index e00dadfc3..caf4f56e6 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -2014,8 +2014,13 @@ async function main() { await mil.update(v); return { groundSnap, milGroundSnap, detectable, airSnap, groundAgainSnap, - droppedGone: !snap('aaa066'), - milDroppedGone: !snap('bbb166'), + // Removed contacts can leave hidden slots in an engine's sprite pool. + droppedGone: !window.__godsEyeView.layers + .drawn('flights') + .some((row) => row.id === 'aaa066'), + milDroppedGone: !window.__godsEyeView.layers + .drawn('military') + .some((row) => row.id === 'bbb166'), }; }); const fmtSnap = (s) => (s ? `show=${s.show} scale=${s.scale.toFixed(3)} rgba=(${s.red.toFixed(2)},${s.green.toFixed(2)},${s.blue.toFixed(2)},${s.alpha.toFixed(2)})` : 'missing'); @@ -2296,12 +2301,28 @@ async function main() { try { p = coll.get(i); } catch { continue; } if (!p) continue; if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; } - if (p.modelMatrix && typeof p.ready !== 'undefined' && p.id === id) found = p; + // A hidden pool slot can keep a removed contact's id: prefer + // the model shown. + if (p.modelMatrix && typeof p.ready !== 'undefined' && p.id === id && (!found || !found.show)) found = p; } }; walk(v.scene.primitives); return found; }; + window.__g3dCountModels = function (id) { + let n = 0; + const walk = (coll) => { + for (let i = 0; i < coll.length; i++) { + let p; + try { p = coll.get(i); } catch { continue; } + if (!p) continue; + if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; } + if (p.modelMatrix && typeof p.ready !== 'undefined' && p.id === id) n++; + } + }; + walk(window.__godsEyeView.viewer.scene.primitives); + return n; + }; }); // (a)+(b): both grounded planes become models under the cap (the old code @@ -2316,8 +2337,19 @@ async function main() { skip('ground-3d: grounded planes are model-eligible and modeled (both layers)', `independent GLB control unavailable (${glbControlDetail}); grounded GLB readiness is not observable`); } else { + // On a miss, say what each layer's own model handle holds. + const g3dWhy = g3dModelsUp ? null : await evalPage(() => { + const dm = window.__godsEyeView.dataManager; + const of = (layerId, id) => { + const { model } = dm.layers.get(layerId).module.testing._aircraftHandlesForTest(id); + return model + ? `${id}: handle ready=${model.ready} show=${model.show} failed=${model.failed}, engine model ready=${window.__g3dFindModel(id)?.ready} show=${window.__g3dFindModel(id)?.show} (${window.__g3dCountModels?.(id)} with this id)` + : `${id}: no model handle`; + }; + return `${of('flights', 'aaa077')}; ${of('military', 'bbb177')}`; + }); record('ground-3d: grounded planes are model-eligible and modeled (both layers)', g3dModelsUp, - g3dModelsUp ? 'fleet models up (ready+shown)' : 'models never became ready+shown'); + g3dModelsUp ? 'fleet models up (ready+shown)' : `models never became ready+shown (${g3dWhy})`); } if (g3dModelsUp) { @@ -2767,8 +2799,10 @@ async function main() { }); await nextFrames(3); const angDiff = (a, b) => Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b))); - const probe = async (id, course) => { - const bb = findBB(id); + const probe = async (layerId, id, course) => { + // Tamper with the layer's own glyph handle: a write to the engine's + // billboard bypasses the layer's sprite cache, so no pass rewrites it. + const bb = dm.layers.get(layerId).module.testing._aircraftHandlesForTest(id).sprite; if (!bb || !bb.show) return { error: `${id} missing/hidden` }; const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id)); @@ -2813,8 +2847,8 @@ async function main() { const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0; const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0; return { - flights: await probe('aaa002', flightCourse), - military: await probe('bbb101', militaryCourse), + flights: await probe('flights', 'aaa002', flightCourse), + military: await probe('military', 'bbb101', militaryCourse), }; }); const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; ${arrivalAngleViolated(a) ? 'angle violation (not retried); ' : ''}${Number.isFinite(a.dMoveEnd) ? `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° ` : ''}${Number.isFinite(a.dReveal) ? `reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° ` : ''}elapsed=${Number(a.elapsedMs).toFixed(1)} ms attempts=${a.attempts}` @@ -3780,24 +3814,13 @@ async function main() { // (a) FLEET: retain the adversarial flags until this contact has passed // through its installed fleet update and that frame has completed. - const findFleetModel = (id) => { - let found = null; - const walk = (coll) => { - const len = coll.length; - for (let i = 0; i < len; i++) { - let p; try { p = coll.get(i); } catch { continue; } - if (!p) continue; - if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; } - if (p.activeAnimations !== undefined && p.minimumPixelSize !== undefined && p.id === id) found = p; - } - }; - walk(v.scene.primitives); - return found; - }; + // The layer's own model handle: the engine's Model is drawn from it, + // and a flag planted on the engine's object never reaches the layer. + const fleetModelOf = (id) => fl.testing._aircraftHandlesForTest(id).model; fl.setParams({ models3d: true }); const fleetDeadline = Date.now() + 20000; let fleetModel = null; - while (Date.now() < fleetDeadline && !(fleetModel = findFleetModel('aaa097'))) { + while (Date.now() < fleetDeadline && !(fleetModel = fleetModelOf('aaa097'))) { await window.__dfSettle(500); } if (!fleetModel) { @@ -3849,7 +3872,7 @@ async function main() { return; } const currentBb = window.__dfFindBB('aaa097'); - const currentModel = findFleetModel('aaa097'); + const currentModel = fleetModelOf('aaa097'); sample = { fleetTickCompleted: true, fleetFrame: v.scene.frameState.frameNumber, @@ -3859,7 +3882,7 @@ async function main() { fleetSeeded: seededFleet, fleetH: currentBb ? window.__dfCarto(currentBb.position).h : null, }; - if (currentBb !== bb || currentModel !== fleetModel || fleetModel.isDestroyed() + if (currentBb !== bb || currentModel !== fleetModel || fl.getTrackedInfo()) sample.error = 'fleet loading fixture changed before its completed frame'; }); armed = true; @@ -4032,13 +4055,17 @@ async function main() { const taxiMeshM = gfns?.cachedMeshFloor?.(taxiCarto.lat, taxiCarto.lon) ?? null; const heldOwns = ns._driveFleetModelHandoffForTest({ icao24: holdIcao, position: taxiPos, course: 90 }); const heldH = window.__dfModelHeight(holdIcao); - const heldBb = !!window.__dfFindBB(holdIcao)?.show; + const heldBb = !!window.__godsEyeView.layers + .drawn('flights') + .find((row) => row.id === holdIcao && !row.trackedModel)?.show; // 3. ~385 m out the memory stops describing anywhere this contact has // been. It is released rather than stretched, and the gate takes over. const farPos = Cesium.Cartesian3.fromDegrees(base.lon + 0.004, base.lat, base.h); const releasedOwns = ns._driveFleetModelHandoffForTest({ icao24: holdIcao, position: farPos, course: 90 }); - const releasedBb = !!window.__dfFindBB(holdIcao)?.show; + const releasedBb = !!window.__godsEyeView.layers + .drawn('flights') + .find((row) => row.id === holdIcao && !row.trackedModel)?.show; fl.setParams({ models3d: false }); await window.__dfSettle(300); diff --git a/scripts/vue-scripts.mjs b/scripts/vue-scripts.mjs new file mode 100644 index 000000000..af7613131 --- /dev/null +++ b/scripts/vue-scripts.mjs @@ -0,0 +1,10 @@ +import { parse } from 'vue/compiler-sfc'; + +/** JavaScript blocks with their original line numbers; templates are not code. */ +export function vueScripts(source, filename) { + const { descriptor, errors } = parse(source, { filename }); + if (errors.length) throw errors[0]; + return [descriptor.script, descriptor.scriptSetup] + .filter(Boolean) + .map((block) => '\n'.repeat(block.loc.start.line - 1) + block.content); +} diff --git a/src/annotations/navaraAnnotationRenderer.js b/src/annotations/navaraAnnotationRenderer.js index ac2bc9f23..f2144cd3d 100644 --- a/src/annotations/navaraAnnotationRenderer.js +++ b/src/annotations/navaraAnnotationRenderer.js @@ -245,6 +245,7 @@ export function createNavaraWorldAnnotationRenderer( export function createNavaraAnnotationRenderer(engine, views, { screen } = {}) { const world = createNavaraWorldAnnotationRenderer(engine, views); const renderer = createHybridAnnotationRenderer(null, { + engine, world, screenScene: screenSceneFromEngine(engine), screen, diff --git a/src/annotations/navaraAnnotationRenderer.test.mjs b/src/annotations/navaraAnnotationRenderer.test.mjs index fe9eefbb3..c4a55666e 100644 --- a/src/annotations/navaraAnnotationRenderer.test.mjs +++ b/src/annotations/navaraAnnotationRenderer.test.mjs @@ -12,6 +12,7 @@ import { createNavaraWorldAnnotationRenderer, drapedParts, } from './navaraAnnotationRenderer.js'; +import { createSceneServices } from '../data/sceneServices.js'; import { screenSceneFromEngine } from './screenScene.js'; for (const type of [globalThis.SVGElement, globalThis.SVGGraphicsElement]) { @@ -265,3 +266,39 @@ test('marks on the Navara board are drawn, horizon-culled and survive an engine engine.fake.frame(); assert.equal(live().length, 0); }); + +test('screen annotations read the attached engines tracked card footprint', async (t) => { + const { store, groups } = board(t); + const engine = createFakeEngine({ + longitude: 0, + latitude: 0, + altitude: 10000, + }); + const services = createSceneServices(engine); + const queries = []; + services.overlay.getPaintRect = (...args) => { + queries.push(args); + return { x: 0, y: 0, w: 4000, h: 4000 }; + }; + const { views } = fakeViews(); + const renderer = createNavaraAnnotationRenderer(engine, views, { + screen: { store, activeTrackedReadoutId: () => 'flight:test' }, + }); + t.after(() => { + renderer.destroy(); + services.destroy(); + engine.destroy(); + }); + renderer.add({ + id: 'overlap', + type: 'label', + color: 'primary', + label: 'Overlap', + alpha: 1, + anchor: { lon: 0, lat: 0, height: 0 }, + }); + engine.fake.frame(); + await settle(); + assert.deepEqual(queries.at(-1), ['tracked', 'flight:test']); + assert.ok(Number(groups()[0].getAttribute('opacity')) < 1); +}); diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index 3fe4246e7..92d0df8d4 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -2297,13 +2297,19 @@ export function createAnnotationResolver({ /** * Whether a depth pick landed on the camera itself: something drawn into * the depth buffer at the near plane (Navara's screen-space preview lines - * and sprites) reads back as a point a metre or so from the camera. Such a - * pick is a miss; the ray's globe hit answers instead. + * and sprites) reads back as a point near the camera, the nearer the lower + * the camera. Such a pick is a miss; the ray's globe hit answers instead. + * The scale is the rendered surface under the ray, not sea level, so a + * camera just above high terrain keeps its ground. */ function depthAtCamera(engine, screen, hit) { const camera = engine.camera.livePosition; - const ground = engine.camera.pickEllipsoid(screen); - const reference = ground ? geo.cartesian3.distance(ground, camera) : 0; + let ground = engine.picker.pickGlobe(screen); + if (!isPickedWorldPosition(ground)) + ground = engine.camera.pickEllipsoid(screen); + const reference = isPickedWorldPosition(ground) + ? geo.cartesian3.distance(ground, camera) + : 0; return geo.cartesian3.distance(hit, camera) < Math.max(2, 0.01 * reference); } @@ -2340,8 +2346,8 @@ export function createAnnotationResolver({ const hit = engine.picker.pickPosition(screen); return hit && !depthAtCamera(engine, screen, hit) ? hit : undefined; }, - () => engine.camera.pickEllipsoid(screen), () => engine.picker.pickGlobe(screen), + () => engine.camera.pickEllipsoid(screen), ]; for (const stage of stages) { let cart = null; diff --git a/src/annotations/resolverHighTerrain.test.mjs b/src/annotations/resolverHighTerrain.test.mjs new file mode 100644 index 000000000..ba8e74825 --- /dev/null +++ b/src/annotations/resolverHighTerrain.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as geo from '../geo/index.js'; +import { pickWorldFromScreen } from './annotationResolver.js'; + +// Camera 30 m above 4000 m terrain, looking straight down: the depth pick +// hits the ground 30 m away; the ellipsoid lies 4030 m away. +test('a depth pick on high terrain close to the camera is kept', () => { + const camera = geo.cartesian3.fromDegrees(86.9, 27.9, 4030); + const ground = geo.cartesian3.fromDegrees(86.9, 27.9, 4000); + const seaLevel = geo.cartesian3.fromDegrees(86.9, 27.9, 0); + const engine = { + host: { size: () => ({ width: 100, height: 100 }) }, + picker: { + pickPositionSupported: true, + pickPosition: () => geo.cartesian3.clone(ground), + pickGlobe: () => geo.cartesian3.clone(ground), + }, + camera: { + livePosition: camera, + pickEllipsoid: () => geo.cartesian3.clone(seaLevel), + }, + }; + const picked = pickWorldFromScreen(engine, 0.5, 0.5); + assert.ok( + Math.abs(picked.height - 4000) < 1, + `picked height ${picked.height}`, + ); +}); + +test('a near-camera depth artifact falls back to terrain before sea level', () => { + const engine = { + host: { size: () => ({ width: 100, height: 100 }) }, + picker: { + pickPositionSupported: true, + pickPosition: () => geo.cartesian3.fromDegrees(86.9, 27.9, 4029), + pickGlobe: () => geo.cartesian3.fromDegrees(86.9, 27.9, 4000), + }, + camera: { + livePosition: geo.cartesian3.fromDegrees(86.9, 27.9, 4030), + pickEllipsoid: () => geo.cartesian3.fromDegrees(86.9, 27.9, 0), + }, + }; + assert.ok(Math.abs(pickWorldFromScreen(engine, 0.5, 0.5).height - 4000) < 1); +}); + +test('a far camera still treats a near-plane depth hit as a miss', () => { + // 100 km up: a preview drawn at the near plane reads back 50 m away. + const camera = geo.cartesian3.fromDegrees(86.9, 27.9, 100_000); + const engine = { + host: { size: () => ({ width: 100, height: 100 }) }, + picker: { + pickPositionSupported: true, + pickPosition: () => geo.cartesian3.fromDegrees(86.9, 27.9, 99_950), + pickGlobe: () => geo.cartesian3.fromDegrees(86.9, 27.9, 4000), + }, + camera: { + livePosition: camera, + pickEllipsoid: () => geo.cartesian3.fromDegrees(86.9, 27.9, 0), + }, + }; + assert.ok(Math.abs(pickWorldFromScreen(engine, 0.5, 0.5).height - 4000) < 1); +}); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 7f305a6d5..a4b77a885 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -5,7 +5,6 @@ import { createApplicationCyclones } from './layers/cyclones.js'; import { createWindLayer } from '../layers/wind/index.js'; import { createLayerCatalog } from './catalog.js'; import { rendererFor } from './layers/renderers.js'; -import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; import { createMilitaryRegistry } from '../layers/aircraft/classification.js'; import { createApplicationFlights } from './layers/flights.js'; @@ -34,7 +33,7 @@ import { createApplicationGhostMaps } from './layers/ghostMaps.js'; import { createApplicationDroneAirspace } from './layers/droneAirspace.js'; import { createApplicationWildlifeSightings } from './layers/wildlifeSightings.js'; import { createApplicationCables } from './layers/submarineCables.js'; -import { OSM_OVERLAYS, createOsmOverlayLayers } from '../data/osmOverlays.js'; +import { createOsmOverlayLayers } from '../data/osmOverlays.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createInfrastructureSessionLayer } from '../layers/infrastructure/index.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; @@ -113,351 +112,214 @@ export function createApplicationCatalog({ }); } } - // Shared instances (awareness and launches read others) are made once, - // when the first layer that needs them is built. - const once = (create) => { - let value; - return () => (value ??= create()); - }; const sessionModel = (id, create) => { if (!layerSession) return create(); const models = (layerSession.models ??= new Map()); if (!models.has(id)) models.set(id, create()); return models.get(id); }; - const flights = once(() => - sessionModel('flights', () => - createApplicationFlights({ + const flights = sessionModel('flights', () => + createApplicationFlights({ + surface, + source: sources.flights, + militaryRegistry, + resolveAsset, + }), + ); + const military = sessionModel('military', () => + createApplicationMilitary({ + surface, + source: sources.military, + militaryRegistry, + resolveAsset, + }), + ); + const vessels = sessionModel('ais-live-vessels', () => + createApplicationVessels({ + source: sources.vessels, + options: vesselOptions, + }), + ); + const installations = sessionModel('military-installations', () => + createApplicationInstallations({ + surface, + source: sources.installations, + }), + ); + const satellites = sessionModel('satellites', () => + createApplicationSatellites({ + source: sources.satellites, + }), + ); + const layers = [ + sessionModel('bhote-koshi-2026', () => + createBhoteKoshiEventLayer({ + createNativeRenderer: (engine) => + rendererFor('bhote-koshi-2026', engine), + }), + ), + sessionModel('bhote-koshi-locator', () => + createBhoteKoshiLocatorLayer({ + boundaryResolver: nepalBoundaryResolver, + createNativeRenderer: (engine, data) => + rendererFor('bhote-koshi-locator', engine, { data }), + }), + ), + flights, + military, + sessionModel('local-adsb', () => + createApplicationLocalAdsb({ surface, - source: sources.flights, - militaryRegistry, - resolveAsset, + // QA fixture receivers (?qaFeed=local-adsb:fixture, dev only). + ...(sources.localAdsbFeeds ? { feeds: sources.localAdsbFeeds } : {}), + enrichment: sources.flights, + displayParams: () => flights.getParams(), + ...(resolveAsset ? { resolveAsset } : {}), }), ), - ); - const military = once(() => - sessionModel('military', () => - createApplicationMilitary({ + sessionModel('earthquakes', () => + createApplicationEarthquakes({ source: sources.earthquakes }), + ), + sessionModel('air-quality', () => + createApplicationAirQuality({ source: sources['air-quality'] }), + ), + sessionModel('fire-perimeters', () => + createApplicationFirePerimeters({ + source: sources['fire-perimeters'], + }), + ), + sessionModel('gnss-integrity', () => + createApplicationGnssIntegrity({ + source: sources['gnss-integrity'], + windowMs: sources['gnss-integrity'].windowMs, + }), + ), + sessionModel('s2-ghostmaps', () => + createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }), + ), + sessionModel('drone-airspace', () => + createApplicationDroneAirspace({ + source: sources['drone-airspace'], + }), + ), + sessionModel('wildlife-sightings', () => + createApplicationWildlifeSightings({ + source: sources['wildlife-sightings'], + groundFloor: surface.groundFloor, + }), + ), + sessionModel('alpr-cameras', () => + createApplicationAlpr({ surface, source: sources.alpr }), + ), + sessionModel('street-level', () => + createApplicationStreetLevel({ surface, - source: sources.military, - militaryRegistry, - resolveAsset, + nativeRenderer: (engine) => rendererFor('street-level', engine), + sources: { mapillary: sources.mapillary }, + providers: streetLevelProviders, }), ), - ); - const vessels = once(() => - sessionModel('ais-live-vessels', () => - createApplicationVessels({ - source: sources.vessels, - options: vesselOptions, + satellites, + sessionModel('rocket-launches', () => + createApplicationLaunches({ + source: sources.launches, + satellites: satellites, }), ), - ); - const installations = once(() => - sessionModel('military-installations', () => - createApplicationInstallations({ + sessionModel('traffic', () => + createApplicationTraffic({ + source: sources.traffic, surface, - source: sources.installations, }), ), - ); - const satellites = once(() => - sessionModel('satellites', () => - createApplicationSatellites({ - source: sources.satellites, + sessionModel('cctv', () => + createApplicationCctv({ surface, source: sources.cctv }), + ), + sessionModel('radio', () => + createApplicationRadio({ surface, source: sources.radio }), + ), + sessionModel('transit', () => + createApplicationTransit({ surface, source: sources.transit }), + ), + sessionModel('bikeshare', () => + createApplicationBikeshare({ source: sources.bikeshare }), + ), + sessionModel('directions', () => + createApplicationDirections({ + source: sources.directions, + surface, }), ), - ); - // Catalog order: [layer id(s), construction]. Only the layers the - // engine can show are constructed (renderers.js). - const entries = [ - [ - 'bhote-koshi-2026', - () => - sessionModel('bhote-koshi-2026', () => - createBhoteKoshiEventLayer({ - createNativeRenderer: (engine) => - rendererFor('bhote-koshi-2026', engine), - }), - ), - ], - [ - 'bhote-koshi-locator', - () => - sessionModel('bhote-koshi-locator', () => - createBhoteKoshiLocatorLayer({ - boundaryResolver: nepalBoundaryResolver, - createNativeRenderer: (engine, data) => - rendererFor('bhote-koshi-locator', engine, { data }), - }), - ), - ], - ['flights', flights], - ['military', military], - [ - 'local-adsb', - () => - sessionModel('local-adsb', () => - createApplicationLocalAdsb({ - surface, - // QA fixture receivers (?qaFeed=local-adsb:fixture, dev only). - ...(sources.localAdsbFeeds - ? { feeds: sources.localAdsbFeeds } - : {}), - enrichment: sources.flights, - displayParams: () => flights().getParams(), - ...(resolveAsset ? { resolveAsset } : {}), - }), - ), - ], - [ - 'earthquakes', - () => - sessionModel('earthquakes', () => - createApplicationEarthquakes({ source: sources.earthquakes }), - ), - ], - [ - 'air-quality', - () => - sessionModel('air-quality', () => - createApplicationAirQuality({ source: sources['air-quality'] }), - ), - ], - [ - 'fire-perimeters', - () => - sessionModel('fire-perimeters', () => - createApplicationFirePerimeters({ - source: sources['fire-perimeters'], - }), - ), - ], - [ - 'gnss-integrity', - () => - sessionModel('gnss-integrity', () => - createApplicationGnssIntegrity({ - source: sources['gnss-integrity'], - windowMs: sources['gnss-integrity'].windowMs, - }), - ), - ], - [ - 's2-ghostmaps', - () => - sessionModel('s2-ghostmaps', () => - createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }), - ), - ], - [ - 'drone-airspace', - () => - sessionModel('drone-airspace', () => - createApplicationDroneAirspace({ - source: sources['drone-airspace'], - }), - ), - ], - [ - 'wildlife-sightings', - () => - sessionModel('wildlife-sightings', () => - createApplicationWildlifeSightings({ - source: sources['wildlife-sightings'], - groundFloor: surface.groundFloor, - }), - ), - ], - [ - 'alpr-cameras', - () => - sessionModel('alpr-cameras', () => - createApplicationAlpr({ surface, source: sources.alpr }), - ), - ], - [ - 'street-level', - () => - sessionModel('street-level', () => - createApplicationStreetLevel({ - surface, - nativeRenderer: (engine) => rendererFor('street-level', engine), - sources: { mapillary: sources.mapillary }, - providers: streetLevelProviders, - }), - ), - ], - ['satellites', satellites], - [ - 'rocket-launches', - () => - sessionModel('rocket-launches', () => - createApplicationLaunches({ - source: sources.launches, - satellites: satellites(), - }), - ), - ], - [ - 'traffic', - () => - sessionModel('traffic', () => - createApplicationTraffic({ - source: sources.traffic, - surface, - }), - ), - ], - [ - 'cctv', - () => - sessionModel('cctv', () => - createApplicationCctv({ surface, source: sources.cctv }), - ), - ], - [ - 'radio', - () => - sessionModel('radio', () => - createApplicationRadio({ surface, source: sources.radio }), - ), - ], - [ - 'transit', - () => - sessionModel('transit', () => - createApplicationTransit({ surface, source: sources.transit }), - ), - ], - [ - 'bikeshare', - () => - sessionModel('bikeshare', () => - createApplicationBikeshare({ source: sources.bikeshare }), - ), - ], - [ - 'directions', - () => - sessionModel('directions', () => - createApplicationDirections({ - source: sources.directions, - surface, - }), - ), - ], - [ - 'recent-imagery', - () => - sessionModel('recent-imagery', () => - createApplicationRecentImagery({ - source: sources['recent-imagery'], - }), - ), - ], - [ - 'osm-places', - () => sessionModel('osm-places', () => createApplicationOsmPlaces()), - ], - ['ais-live-vessels', vessels], - ['military-installations', installations], - [ - 'military-awareness', - () => - sessionModel('military-awareness', () => - createApplicationAwareness({ - flights: flights(), - military: military(), - vessels: vessels(), - installations: installations(), - }), - ), - ], - [ - 'wind', - () => - sessionModel('wind', () => - createWindLayer({ - feed: sources.wind, - clock: weatherClock, - createNativeRendering: (engine, options) => - rendererFor('wind', engine, options), - }), - ), - ], - ...['weather-radar', 'weather-satellite', 'weather-lightning'].map( - (id) => [ - id, - () => - sessionModel(id, () => - createWeatherLayer({ - feed: sources.weather, - id, - clock: weatherClock, - createNativeRendering: (engine, options) => - rendererFor(id, engine, options), + sessionModel('recent-imagery', () => + createApplicationRecentImagery({ + source: sources['recent-imagery'], + }), + ), + sessionModel('osm-places', () => createApplicationOsmPlaces()), + vessels, + installations, + sessionModel('military-awareness', () => + createApplicationAwareness({ + flights: flights, + military: military, + vessels: vessels, + installations: installations, + }), + ), + sessionModel('wind', () => + createWindLayer({ + feed: sources.wind, + clock: weatherClock, + createNativeRendering: (engine, options) => + rendererFor('wind', engine, options), + }), + ), + ...['weather-radar', 'weather-satellite', 'weather-lightning'].map((id) => + sessionModel(id, () => + createWeatherLayer({ + feed: sources.weather, + id, + clock: weatherClock, + createNativeRendering: (engine, options) => + rendererFor(id, engine, options), + }), + ), + ), + sessionModel('weather-cyclones', () => + createApplicationCyclones({ + feed: sources.cyclones, + createNativeRendering: (engine, options) => + rendererFor('weather-cyclones', engine, options), + }), + ), + ...sessionModel('osm-overlays', () => createOsmOverlayLayers()), + ...sessionModel('local-infrastructure', () => + createInfrastructureLayers(localGeoJsonServices, (config, services) => + createInfrastructureSessionLayer( + config, + services, + (engine, descriptor, ops) => + rendererFor(config.id, engine, { + config: descriptor, + services: ops, }), - ), - ], + ), + ), + ), + sessionModel('telegeography-submarine-cables', () => + createApplicationCables({ source: sources.cables, session: true }), + ), + sessionModel('local-firms', () => + createApplicationFirms({ + surface, + id: 'local-firms', + name: 'FIRMS Active Fires', + icon: '▲', + source: 'NASA FIRMS · LIVE', + feed: sources.firms, + }), ), - [ - 'weather-cyclones', - () => - sessionModel('weather-cyclones', () => - createApplicationCyclones({ - feed: sources.cyclones, - createNativeRendering: (engine, options) => - rendererFor('weather-cyclones', engine, options), - }), - ), - ], - [ - OSM_OVERLAYS.map(({ id }) => id), - () => sessionModel('osm-overlays', () => createOsmOverlayLayers()), - ], - [ - ['local-datacenters', 'local-dams'], - () => - sessionModel('local-infrastructure', () => - createInfrastructureLayers( - localGeoJsonServices, - (config, services) => - createInfrastructureSessionLayer( - config, - services, - (engine, descriptor, ops) => - rendererFor(config.id, engine, { - config: descriptor, - services: ops, - }), - ), - ), - ), - ], - [ - 'telegeography-submarine-cables', - () => - sessionModel('telegeography-submarine-cables', () => - createApplicationCables({ source: sources.cables, session: true }), - ), - ], - [ - 'local-firms', - () => - sessionModel('local-firms', () => - createApplicationFirms({ - surface, - id: 'local-firms', - name: 'FIRMS Active Fires', - icon: '▲', - source: 'NASA FIRMS · LIVE', - feed: sources.firms, - }), - ), - ], ]; - const layers = entries.flatMap(([, create]) => create()); const catalog = createLayerCatalog(layers, metadata); return Object.freeze({ ...catalog, diff --git a/src/app/controls.js b/src/app/controls.js index 2706ee355..cb8c47988 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -46,8 +46,6 @@ export async function createApplicationControls({ const styleManager = new Controls(viewer, { services: { ...sharedServices, - // The catalog's layers; the ones this engine cannot show (Navara) - // are empty stand-ins. ...catalogControlServices(catalog), }, requestServices: operations.requests, diff --git a/src/app/engineChoice.js b/src/app/engineChoice.js index 95d7ce9c8..7d6b38ff7 100644 --- a/src/app/engineChoice.js +++ b/src/app/engineChoice.js @@ -8,6 +8,9 @@ export const ENGINE_STORAGE_KEY = 'gev.engine'; */ export const ENGINE_CHOICES = Object.freeze(['cesium', 'navara', 'navara-hd']); +/** The choice without one made, and the one a failed start falls back to. */ +export const DEFAULT_ENGINE_CHOICE = ENGINE_CHOICES[0]; + /** The engine a choice runs: both Navara choices are the Navara engine. */ export const engineOfChoice = (choice) => choice === 'cesium' ? 'cesium' : 'navara'; @@ -21,12 +24,16 @@ const known = (value) => { /** * The globe choice for this page: `?engine=cesium|navara|navara-hd`, else - * the stored `gev.engine`, else Cesium. Navara is experimental; an unknown or + * the stored `gev.engine`, else `fallback` (Cesium). Navara is experimental; an unknown or * unreadable value never selects it. - * @param {{ search?: string, storage?: { getItem(key: string): string | null } | null }} [where] + * @param {{ search?: string, storage?: { getItem(key: string): string | null } | null, fallback?: string }} [where] * @returns {'cesium' | 'navara' | 'navara-hd'} */ -export function resolveEngineKind({ search = '', storage = null } = {}) { +export function resolveEngineKind({ + search = '', + storage = null, + fallback = DEFAULT_ENGINE_CHOICE, +} = {}) { const fromQuery = known(new URLSearchParams(search).get('engine')); if (fromQuery) return fromQuery; let stored = null; @@ -35,5 +42,5 @@ export function resolveEngineKind({ search = '', storage = null } = {}) { } catch { // Storage can be blocked (private mode, sandboxed frames). } - return known(stored) ?? 'cesium'; + return known(stored) ?? known(fallback) ?? DEFAULT_ENGINE_CHOICE; } diff --git a/src/app/engineVisitStorage.test.mjs b/src/app/engineVisitStorage.test.mjs new file mode 100644 index 000000000..302746f24 --- /dev/null +++ b/src/app/engineVisitStorage.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createSceneHost } from './sceneHost.js'; +import { resolveEngineKind, ENGINE_STORAGE_KEY } from './engineChoice.js'; + +test('a history visit without ?engine= keeps the engine the page started with', async () => { + // Tab A: no ?engine= in its address, started Navara from the stored choice. + const store = new Map([[ENGINE_STORAGE_KEY, 'navara']]); + const storage = { getItem: (key) => store.get(key) ?? null }; + const search = '?view=1'; // no engine parameter + const initial = resolveEngineKind({ search, storage }); + const requested = () => resolveEngineKind({ search, fallback: initial }); + assert.equal(initial, 'navara'); + const entered = []; + const switcher = createSceneHost().createSwitcher({ + kind: initial, + requested, + signal: new AbortController().signal, + leave: async () => {}, + enter: async (kind) => entered.push(kind), + address: { select: () => null, commit() {}, restore() {} }, + settle: async () => {}, + }); + // Tab B picks Cesium and remembers it in the shared storage. + store.set(ENGINE_STORAGE_KEY, 'cesium'); + // Tab A: a popstate (a pasted share link that changes only the hash). + await switcher.reconcile(); + assert.deepEqual(entered, [], `tab A switched to ${entered.join(',')}`); + assert.equal(switcher.state().engine, 'navara'); +}); diff --git a/src/app/layerSession.test.mjs b/src/app/layerSession.test.mjs index 200332186..15ff8cc54 100644 --- a/src/app/layerSession.test.mjs +++ b/src/app/layerSession.test.mjs @@ -29,6 +29,7 @@ test('a session retains layer intentions through handoff but never after page te entry, enabled: true, durable: true, + visit: false, }); resumeSession(session); assert.deepEqual(calls, ['suspend', 'viewport', 'resume']); diff --git a/src/app/layers/aisLiveVessels.js b/src/app/layers/aisLiveVessels.js index 1b25e446c..078379188 100644 --- a/src/app/layers/aisLiveVessels.js +++ b/src/app/layers/aisLiveVessels.js @@ -12,7 +12,6 @@ import * as selection from '../../data/selectionDispatcher.js'; import { rendererFor } from './renderers.js'; import * as readouts from '../../hudReadouts.js'; -/** Construct one layer using the application scene owners and a supplied source. */ export function createApplicationVessels({ source, options = {}, diff --git a/src/app/layers/alprCameras.js b/src/app/layers/alprCameras.js index 1465f665b..c996eb226 100644 --- a/src/app/layers/alprCameras.js +++ b/src/app/layers/alprCameras.js @@ -9,11 +9,6 @@ import * as picking from '../../data/pickRegistry.js'; import { keyholeLabelAlphaFromGeometry } from '../../celestialRing.js'; import { refreshTrackedReadout } from '../../data/trackedReadout.js'; -/** - * Construct one layer using the application scene owners and a supplied - * source; it draws with the engine's renderer from the manifest - * (render/display.js on every engine). - */ export function createApplicationAlpr({ surface, source, diff --git a/src/app/layers/bikeshare.js b/src/app/layers/bikeshare.js index ba899c487..bb92eac9e 100644 --- a/src/app/layers/bikeshare.js +++ b/src/app/layers/bikeshare.js @@ -4,11 +4,6 @@ import * as render from '../../renderGovernor.js'; import * as picking from '../../data/pickRegistry.js'; import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js'; -/** - * Construct one layer using the application scene owners and a supplied - * source; it draws with the engine's renderer from the manifest - * (render/display.js on every engine). - */ export function createApplicationBikeshare({ source }) { return createBikeshareLayer({ source, diff --git a/src/app/layers/cctv.js b/src/app/layers/cctv.js index ffb9909af..ec09b0bdc 100644 --- a/src/app/layers/cctv.js +++ b/src/app/layers/cctv.js @@ -8,11 +8,6 @@ import * as picking from '../../data/pickRegistry.js'; import * as focus from '../../data/focusDeemphasis.js'; import * as render from '../../renderGovernor.js'; -/** - * Construct one layer using the application scene owners and a supplied - * source; it draws with the engine's renderer from the manifest - * (render/display.js on every engine). - */ export function createApplicationCctv({ surface, source, diff --git a/src/app/layers/flights.js b/src/app/layers/flights.js index d3d942b57..943d977d3 100644 --- a/src/app/layers/flights.js +++ b/src/app/layers/flights.js @@ -12,7 +12,6 @@ import * as context from '../../data/contextStore.js'; import * as render from '../../renderGovernor.js'; import * as recession from '../../data/aircraftRecession.js'; -/** Construct one layer using the application scene owners and a supplied source. */ export function createApplicationFlights({ surface, source, diff --git a/src/app/layers/militaryFlights.js b/src/app/layers/militaryFlights.js index 0e58266a8..fce784d39 100644 --- a/src/app/layers/militaryFlights.js +++ b/src/app/layers/militaryFlights.js @@ -12,7 +12,6 @@ import * as context from '../../data/contextStore.js'; import * as render from '../../renderGovernor.js'; import * as recession from '../../data/aircraftRecession.js'; -/** Construct one layer using the application scene owners and a supplied source. */ export function createApplicationMilitary({ surface, source, diff --git a/src/app/layers/militaryInstallations.js b/src/app/layers/militaryInstallations.js index 3fc78cd02..8cd558e36 100644 --- a/src/app/layers/militaryInstallations.js +++ b/src/app/layers/militaryInstallations.js @@ -6,11 +6,6 @@ import * as render from '../../renderGovernor.js'; import * as context from '../../data/contextStore.js'; import * as picking from '../../data/pickRegistry.js'; -/** - * Construct one layer using the application scene owners and a supplied - * source; it draws with the engine's renderer from the manifest - * (render/display.js on every engine). - */ export function createApplicationInstallations({ surface, source }) { const { groundFloor: ground, anchors } = surface; return createInstallationsLayer({ diff --git a/src/app/layers/qaSources.js b/src/app/layers/qaSources.js index 616c97172..c16c4d824 100644 --- a/src/app/layers/qaSources.js +++ b/src/app/layers/qaSources.js @@ -31,6 +31,9 @@ import { satelliteFixture } from '../../bench/satelliteFixture.js'; import { createSatelliteSource } from '../../layers/satellites/source.js'; import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js'; +// The largest synthetic fleet used by the QA scenarios. +const MAX_QA_COUNT = 10_000; + const KINDS = Object.freeze({ flights: new Set(['synthetic']), military: new Set(['synthetic']), @@ -49,7 +52,8 @@ export function parseQaFeed(search = '') { const count = rawCount === undefined ? null : Number(rawCount); if ( !KINDS[layer]?.has(kind) || - (count !== null && !(Number.isInteger(count) && count > 0)) + (count !== null && + !(Number.isSafeInteger(count) && count > 0 && count <= MAX_QA_COUNT)) ) throw new TypeError( `qaFeed: unknown entry "${item}" (flights:synthetic[:count], military:synthetic[:count], vessels:fixture, satellites:fixture, launches:fixture, local-adsb:fixture)`, diff --git a/src/app/layers/qaSources.test.mjs b/src/app/layers/qaSources.test.mjs index 372e931df..7c98ac17f 100644 --- a/src/app/layers/qaSources.test.mjs +++ b/src/app/layers/qaSources.test.mjs @@ -133,3 +133,19 @@ test('vessel fixture answers a relative track URL and retains its full snapshot' }); assert.equal((await vessels.getSnapshot({})).records.length, 12000); }); + +test('fixture counts cannot exceed the QA fleet budget', () => { + for (const count of [ + '10001', + '1e9', + '9007199254740992', + 'Infinity', + '0', + '1.5', + ]) + assert.throws( + () => parseQaFeed(`?qaFeed=flights:synthetic:${count}`), + TypeError, + ); + assert.equal(parseQaFeed('?qaFeed=flights:synthetic:10000')[0].count, 10000); +}); diff --git a/src/app/layers/radio.js b/src/app/layers/radio.js index de1c6e7b7..d28096469 100644 --- a/src/app/layers/radio.js +++ b/src/app/layers/radio.js @@ -5,11 +5,6 @@ import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js'; import * as globe from '../../celestialRing.js'; import * as render from '../../renderGovernor.js'; -/** - * Construct one layer using the application scene owners and a supplied - * source; it draws with the engine's renderer from the manifest - * (render/display.js on every engine). - */ export function createApplicationRadio({ surface, source, diff --git a/src/app/layers/rocketLaunches.js b/src/app/layers/rocketLaunches.js index 0bf7e371f..ddc6255e8 100644 --- a/src/app/layers/rocketLaunches.js +++ b/src/app/layers/rocketLaunches.js @@ -4,7 +4,6 @@ import * as geometry from '../../celestialRing.js'; import { worldOverlayFor as overlays } from '../../overlays/worldOverlay.js'; import * as render from '../../renderGovernor.js'; -/** Construct one layer using the application scene owners and a supplied source. */ export function createApplicationLaunches({ source, satellites, diff --git a/src/app/layers/satellites.js b/src/app/layers/satellites.js index e613a9c2f..7282e10bd 100644 --- a/src/app/layers/satellites.js +++ b/src/app/layers/satellites.js @@ -8,7 +8,6 @@ import * as context from '../../data/contextStore.js'; import * as render from '../../renderGovernor.js'; import * as layerState from '../../data/layerState.js'; -/** Construct one layer using the application scene owners and a supplied source. */ export function createApplicationSatellites({ source, // Test seam: the renderer factory (default: the engine's, from the diff --git a/src/app/layers/traffic.js b/src/app/layers/traffic.js index b999c6757..63c0e5bbe 100644 --- a/src/app/layers/traffic.js +++ b/src/app/layers/traffic.js @@ -4,11 +4,6 @@ import * as ground from '../../data/groundFloor.js'; import * as render from '../../renderGovernor.js'; import { rendererFor } from './renderers.js'; -/** - * Construct one layer using the application scene owners and a supplied - * source; it draws with the engine's renderer from the manifest - * (render/display.js on every engine). - */ export function createApplicationTraffic({ source, surface }) { return createTrafficLayer({ source, diff --git a/src/app/loadCesium.js b/src/app/loadCesium.js index f3589e0b1..c7fb68c90 100644 --- a/src/app/loadCesium.js +++ b/src/app/loadCesium.js @@ -7,7 +7,7 @@ // - `viewer()`: a Cesium globe is about to start: the SDK and the widget // styles; // - `sdk()`: a feature that still builds on Cesium's entity model (the Nepal -// event and locator, imported KML) is used on Navara: the SDK alone. +// event and locator) is used on Navara: the SDK alone. export function createCesiumLoader({ document = globalThis.document, globals = globalThis, @@ -45,6 +45,9 @@ export function createCesiumLoader({ const ready = () => !script || Object.getOwnPropertyDescriptor(globals, 'Cesium')?.value != null; + // A page whose markup ran Cesium.js before this loader also carries its + // widget styles (the MCP panel build, which cannot load files by URL). + const preloaded = script && ready(); const sdk = () => { if (ready()) return Promise.resolve(); sdkPending ??= load('script', `${root()}Cesium.js`).catch((error) => { @@ -54,6 +57,7 @@ export function createCesiumLoader({ return sdkPending; }; const styles = () => { + if (preloaded) return Promise.resolve(); stylesPending ??= load('link', `${root()}Widgets/widgets.css`).catch( (error) => { stylesPending = null; diff --git a/src/app/loadCesium.test.mjs b/src/app/loadCesium.test.mjs index 1b91861a9..475417e3f 100644 --- a/src/app/loadCesium.test.mjs +++ b/src/app/loadCesium.test.mjs @@ -76,6 +76,14 @@ test('the SDK is not loaded where the page already has it or imports its module' assert.equal(dev.elements.length, 0); }); +test('a page that ran Cesium.js from its markup loads no styles either', async () => { + // The panel build's HTML lists Cesium.js and widgets.css; the panel host + // answers only those, not a stylesheet added later. + const { elements, loader } = fixture({ Cesium: {} }); + await loader.viewer(); + assert.equal(elements.length, 0); +}); + test('a failed Cesium asset load can be retried and preserves the configured base', async () => { const { elements, globals, loader } = fixture({ CESIUM_BASE_URL: '/custom/', diff --git a/src/app/mainStartup.test.mjs b/src/app/mainStartup.test.mjs new file mode 100644 index 000000000..5cd0e1635 --- /dev/null +++ b/src/app/mainStartup.test.mjs @@ -0,0 +1,120 @@ +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import * as choices from './engineChoice.js'; + +const source = readFileSync(new URL('../main.js', import.meta.url), 'utf8') + .replace(/^import[\s\S]*?;\n/gm, '') + .replaceAll('import.meta.env', 'env') + .replace(/export \{ application \};/, ''); +const settle = async () => { + for (let i = 0; i < 15; i++) await new Promise((r) => setTimeout(r, 0)); +}; + +function boot(t, { search = '', failures = ['navara'] } = {}) { + document.body.innerHTML = + '

'; + const starts = []; + const switches = []; + const toasts = []; + const stored = new Map([[choices.ENGINE_STORAGE_KEY, 'navara']]); + const location = { search, href: `https://example.test/${search}` }; + const localStorage = { + getItem: (key) => stored.get(key), + setItem: (key, value) => stored.set(key, value), + }; + const lifetime = new AbortController(); + t.after(() => lifetime.abort()); + const deps = { + ...choices, + env: {}, + location, + localStorage, + history: { replaceState() {} }, + guardCesiumGlobal() {}, + t: (key, values = {}) => + key.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? ''), + describeError: (error) => error.message, + createSessionDiagnostics: () => ({ count() {} }), + createRendererCache: () => ({ clear() {} }), + createLayerSession: () => ({ lifetime }), + useDisplayStore: () => ({ presentGlobeEngine() {} }), + applicationPinia() {}, + pageEngineKind: () => + choices.resolveEngineKind({ + search: location.search, + storage: localStorage, + }), + createEngineAddress: () => ({ + restore(kind) { + location.search = `?engine=${kind}`; + }, + }), + createSceneSwitcher: (options) => { + switches.push(options); + return { + state: () => ({ + kind: options.kind, + engine: options.kind, + busy: false, + }), + reconcile: async () => {}, + select: async () => {}, + }; + }, + createStandaloneApplication: ({ engineKind }) => ({ + async start() { + starts.push(engineKind); + if (failures.includes(engineKind)) throw new Error('Engine failed'); + }, + async destroy() {}, + getState: () => ({ + status: failures.includes(engineKind) ? 'failed' : 'ready', + }), + getComponents: () => ({ + scene: { engine: {} }, + controls: { styleManager: { _showToast: (text) => toasts.push(text) } }, + }), + }), + console: { error() {} }, + }; + new Function(...Object.keys(deps), source)(...Object.values(deps)); + return { starts, switches, stored, toasts }; +} + +test('a failed stored engine falls back to Cesium and remembers the recovery', async (t) => { + const f = boot(t); + await settle(); + assert.deepEqual(f.starts, ['navara', 'cesium']); + assert.equal(f.stored.get(choices.ENGINE_STORAGE_KEY), 'cesium'); + assert.equal(f.switches[0].kind, 'cesium'); + assert.deepEqual(f.toasts, [ + 'Globe engine failed to start. Running Cesium: Engine failed', + ]); +}); + +test('a failed explicit first engine offers a usable retry picker', async (t) => { + const f = boot(t, { search: '?engine=navara' }); + await settle(); + assert.deepEqual(f.starts, ['navara']); + const retry = document.querySelector('[data-engine="cesium"]'); + assert.ok(retry); + retry.click(); + await settle(); + assert.deepEqual(f.starts, ['navara', 'cesium']); + assert.equal(f.switches[0].kind, 'cesium'); + assert.equal(document.querySelector('.engine-switch-retry'), null); +}); + +test('a recovered engine switch names the globe that remains running', async (t) => { + const f = boot(t, { search: '?engine=cesium', failures: [] }); + await settle(); + f.switches[0].onFailure(new Error('Initialization failed'), { + recovered: true, + kind: 'cesium', + }); + assert.deepEqual(f.toasts, [ + 'Globe engine failed to start. Running Cesium: Initialization failed', + ]); +}); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 074d0bb50..9fcc7435f 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -9,6 +9,7 @@ import { registerDataCredits } from '../data/dataCredits.js'; import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js'; import { LAYER_MANIFEST } from './layers/renderers.js'; import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js'; +import { localizeLayerName } from '../ui/layerNames.js'; /** The Navara globe module, loaded on demand. */ const loadNavaraGlobeModule = async () => @@ -26,8 +27,10 @@ export function navaraModeNotice(manifest = LAYER_MANIFEST) { return Object.freeze({ label: t('Experimental Navara engine'), detail: names.length - ? `Data layers: ${names.join(', ')}` - : 'No registered data layers', + ? t('Data layers: {layers}', { + layers: names.map(localizeLayerName).join(', '), + }) + : t('No registered data layers'), }); } @@ -101,7 +104,7 @@ export async function createNavaraScene({ /** * The Navara tool backend (src/app/tools.js) of one Navara scene: where the - * annotation board and Draw draw (on the engine and Navara's view; null + * annotation board draws (on the engine and Navara's view; null * without the view), the Recent Imagery panel's globe, the render * governor on the engine, and this engine's own parts: the daylight of a * restored view and the engine notice. @@ -115,8 +118,6 @@ export function navaraToolBackend({ engine }) { drawn ? { engine, renderer: createNavaraAnnotationRenderer(engine, views) } : null, - // The session's tool on the engine's input and preview. - draw: () => ({ engine }), recentImagery: () => ({ engine }), attach({ styleManager }, { signal, defer }) { void styleManager.initialRestorePromise?.then(() => { diff --git a/src/app/tools.js b/src/app/tools.js index bba25c22e..51ddbb9da 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -272,7 +272,6 @@ function cesiumToolBackend({ engine, tileset }) { viewer, startupChrome: {}, board: () => ({ viewer, engine, tileset }), - draw: () => ({ engine }), // The tileset lets the layer drape while the globe is hidden. recentImagery(layer) { layer.attachTileset(tileset); @@ -353,7 +352,7 @@ async function attachEngineTools( { layerSession, placeSearch, operations }, defer, ); - attachDraw(backend.draw, { layerSession, annotations, styleManager }, defer); + attachDraw(engine, { layerSession, annotations, styleManager }, defer); // Import draws through the globe contract, the same on every engine. attachGeoImport( { engine }, @@ -483,13 +482,13 @@ function attachBoard( } /** - * UTILITIES ▸ Draw on this globe (`drawScene()`: its input and preview): + * UTILITIES ▸ Draw on this globe (its engine input and preview): * the tool claims the globe's pointer while a sketch is open, so a switch * ends an open sketch and keeps the chosen shape and colour. Returns the * tool (null on a page without it or without the board). */ function attachDraw( - drawScene, + engine, { layerSession, annotations, styleManager }, defer, ) { @@ -500,7 +499,7 @@ function attachDraw( styleManager, defer, }); - drawTool?.attachScene({ ...drawScene(), navigation: styleManager }); + drawTool?.attachScene({ engine, navigation: styleManager }); defer(() => drawTool?.detachScene()); return drawTool; } diff --git a/src/celestialRing.js b/src/celestialRing.js index de7797041..eb240b569 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -410,6 +410,7 @@ export class CelestialRing { viewer, { enabled = true, onAutoDisable = null, store, engine = null } = {}, ) { + this._engine = engine ?? (viewer?.kind ? viewer : null); this._engineView = viewer?.kind ? createCelestialView(viewer) : null; this.viewer = this._engineView?.view ?? viewer; this._projector = engine?.projector ?? null; @@ -603,7 +604,7 @@ export class CelestialRing { this._focusInProgress = false; this.viewer.scene.requestRender?.(); }; - flyCelestialViewer(this.viewer, { + const flight = { destination: geo.cartesian3.fromRadians( cartographic.longitude, cartographic.latitude, @@ -620,7 +621,10 @@ export class CelestialRing { finishFocus(); if (!this.isGlobeFullyVisible()) this._autoDisable(); }, - }); + }; + if (this._engine) + this._engine.camera.flyTo({ ...flight, easing: 'cubicInOut' }); + else flyCelestialViewer(this.viewer, flight); return true; } diff --git a/src/celestialRingFocus.test.mjs b/src/celestialRingFocus.test.mjs new file mode 100644 index 000000000..382d5c8ef --- /dev/null +++ b/src/celestialRingFocus.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { CelestialRing } from './celestialRing.js'; + +test('full globe focus uses the engine without a Cesium global', () => { + let flight; + const engine = { + camera: { + flyTo: (options) => { + flight = options; + }, + }, + }; + const ring = Object.assign(Object.create(CelestialRing.prototype), { + _engine: engine, + viewer: { + scene: { + canvas: { clientWidth: 1200, clientHeight: 800 }, + requestRender() {}, + }, + camera: { + positionCartographic: { longitude: 0, latitude: 0 }, + heading: 0, + frustum: { fovy: Math.PI / 3 }, + }, + }, + }); + assert.equal(globalThis.Cesium, undefined); + assert.equal(ring.focusFullGlobe(), true); + assert.equal(flight.easing, 'cubicInOut'); + assert.equal(ring._focusInProgress, true); + flight.complete(); + assert.equal(ring._focusInProgress, false); +}); diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index b99b07483..19ff16b81 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -662,9 +662,7 @@ class CockpitCloudEffectsController { } /** - * Free the GPU objects and the canvas's WebGL context. The application is - * rebuilt on every globe switch, and browsers cap live contexts, so a - * destroyed controller must not leave its context for garbage collection. + * Free GPU objects and the canvas's WebGL context: browsers cap live contexts. */ releaseRenderer() { const gl = this.gl; diff --git a/src/cyberSonarEngine.js b/src/cyberSonarEngine.js index 1598c1403..3d80c72d4 100644 --- a/src/cyberSonarEngine.js +++ b/src/cyberSonarEngine.js @@ -1,6 +1,6 @@ // The Cyber HUD sonar's contact treatment on the globe engine, which draws // it (render.setContactSonar, the `contactSonar` capability: Navara's -// sprite and point movers, Cesium's native draw commands and models). Each +// movers, Cesium's native draw commands and models). Each // drawn frame hands the engine the theme's state, the sweep's settings and // its angle on the shared clock (the CSS sweep's), so the contacts and the // rings turn together; while the sweep runs over visible contacts a parked @@ -36,12 +36,9 @@ export function cyberSonarEngineState( }; } -/** Sprite and point instances the engine draws (models keep their colours). */ +/** Every mover instance the engine holds, on screen or not. */ const contactCount = (engine) => - engine.movers - .list() - .filter((set) => set.kind !== 'model') - .reduce((count, set) => count + set.count, 0); + engine.movers.list().reduce((count, set) => count + set.count, 0); const UNSUPPORTED_MESSAGE = 'Contact GPU unavailable. Native contacts remain visible.'; @@ -85,12 +82,13 @@ export function createCyberSonarEngineScene( if (isCyberSonarActive({ root })) syncCyberSonarAnimation(root); engine.render.setContactSonar(cyberSonarEngineState(now(), root)); }); - // Contacts the sweep passes over: the engine's movers, and what its own - // treatment drew (Cesium's tinted models and visible native batches). + // Contacts the sweep passes over on screen, as the engine's treatment + // reports them (Navara's movers in view; Cesium's tinted models and + // visible native batches); an engine without a report, all its movers. const contactsShown = () => { - if (contactCount(engine)) return true; const drawn = diagnostics(); - return Boolean(drawn?.contacts || drawn?.gpu?.visibleCommands); + if (!drawn) return contactCount(engine) > 0; + return Boolean(drawn.contacts || drawn.gpu?.visibleCommands); }; const removePostRender = engine.frames.postRender.addEventListener(() => { // A treatment lost while the scene is parked (Sonar OFF, reduced diff --git a/src/cyberSonarEngine.test.mjs b/src/cyberSonarEngine.test.mjs index 402288634..adf8d62df 100644 --- a/src/cyberSonarEngine.test.mjs +++ b/src/cyberSonarEngine.test.mjs @@ -166,3 +166,36 @@ test("an engine's own contacts keep the sweep drawing, and a lost treatment wake dispose(); assert.equal(root.dataset.cyberSonarGpu, undefined); })); + +test('contacts held off screen leave the sweep idle', () => + withCyberPage(async ({ root }) => { + const engine = createFakeEngine({ kind: 'navara', contactSonar: true }); + // Navara's report (createNavaraEngine.js): the movers in view. + const report = { supported: true, contacts: 0 }; + engine.contactSonarDiagnostics = () => report; + const timers = new Map(); + let nextTimer = 1; + const dispose = createCyberSonarEngineScene(engine, null, { + root, + setTimer: (callback, ms) => { + timers.set(nextTimer, [callback, ms]); + return nextTimer++; + }, + clearTimer: (id) => timers.delete(id), + }); + const set = engine.movers.createSet({ + id: 'qa-contacts', + kind: 'point', + capacity: 1, + point: { sizePx: 8 }, + }); + set.set(0, { position: { x: 6378137, y: 0, z: 0 } }); + set.setCount(1); + set.commit(); + engine.fake.frame(); + assert.equal(timers.size, 0, 'a contact out of view draws nothing'); + report.contacts = 1; + engine.fake.frame(); + assert.equal(timers.size, 1, 'one in view keeps the sweep moving'); + dispose(); + })); diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 5748358fa..355733ee4 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -44,6 +44,8 @@ const SCENE_REVEAL_RENDER_HOLD = 'bhote-koshi-scene-beat-reveal'; const SCENE_PATH_RENDER_HOLD = 'bhote-koshi-scene-path-travel'; const SCENE_MEDIA_MIN_PLAYBACK_SECONDS = 6; const SCENE_MEDIA_EXIT_SECONDS = 0.65; +// A camera this near an evidence anchor has the valley's terrain loaded. +const EVIDENCE_REFINE_M = 25_000; const BHOTE_EVENT_ACCENT = WORLD_OVERLAY_STYLE.accent; const FLOOD_COLOR = () => Cesium.Color.fromCssColorString('#d8954e').withAlpha(0.68); @@ -1050,6 +1052,9 @@ export function createBhoteKoshiEventLayer({ let _evidenceTimeline = []; let _evidenceAnchors = []; let _evidenceFrameSlots = []; + let _evidenceEntries = []; + // Evidence anchors at the pack's elevations (no terrain sample). + const _fallbackAnchors = new WeakSet(); let _evidenceVideo = null; let _evidenceVideoIndex = -1; let _evidenceVideoReady = false; @@ -1198,14 +1203,17 @@ export function createBhoteKoshiEventLayer({ : []; let sampled = null; try { - sampled = - _engine && !_viewer - ? observations.map(({ lon, lat }) => ({ - height: _engine.surface.terrainHeight( - Cesium.Cartographic.fromDegrees(lon, lat), - ), - })) - : await terrainSampler(_viewer, observations, { signal }); + if (_engine && !_viewer) { + const revision = _engine.maps.terrainRevision(); + const heights = await _engine.surface.sampleTerrainMostDetailed( + observations.map(({ lon, lat }) => + Cesium.Cartographic.fromDegrees(lon, lat), + ), + ); + // Heights of a terrain replaced meanwhile are not the one shown. + if (_engine.maps.terrainRevision() === revision) + sampled = heights.map((height) => ({ height })); + } else sampled = await terrainSampler(_viewer, observations, { signal }); } catch (error) { if (error?.name === 'AbortError') throw error; console.warn( @@ -1217,14 +1225,35 @@ export function createBhoteKoshiEventLayer({ return observations.map((observation, index) => { const sampledHeight = Number(sampled?.[index]?.height); const fallbackHeight = Number(observation.fallbackElevationM) || 0; - return Cesium.Cartesian3.fromDegrees( + const anchor = Cesium.Cartesian3.fromDegrees( observation.lon, observation.lat, (Number.isFinite(sampledHeight) ? sampledHeight : fallbackHeight) + 18, ); + if (!Number.isFinite(sampledHeight)) _fallbackAnchors.add(anchor); + return anchor; }); } + // An anchor at the pack's elevation, on Navara, moves onto the terrain + // loaded under a near camera (the valley's, not an overview tile's), in + // place, so the card's cull position follows. + function refineEvidenceAnchor(index) { + const anchor = _evidenceAnchors[index]; + if (!anchor || _viewer || !_engine || !_fallbackAnchors.has(anchor)) + return anchor || null; + const live = _engine.camera.livePosition; + if (!live || Cesium.Cartesian3.distance(live, anchor) > EVIDENCE_REFINE_M) + return anchor; + const { lon, lat } = _evidenceTimeline[index]?.observation ?? {}; + const height = Number.isFinite(lon) + ? _engine.surface.terrainHeight(Cesium.Cartographic.fromDegrees(lon, lat)) + : undefined; + if (Number.isFinite(height)) + Cesium.Cartesian3.fromDegrees(lon, lat, height + 18, undefined, anchor); + return anchor; + } + function evidencePositionGetter(index, breadcrumb = false) { return () => { if (!_enabled) return null; @@ -1245,13 +1274,13 @@ export function createBhoteKoshiEventLayer({ _sceneMediaPlaybackCompletedBeatId ) return null; - return _evidenceAnchors[index] || null; + return refineEvidenceAnchor(index); } const activeIndex = activeEvidenceIndex(_progress, _evidenceTimeline); if (breadcrumb ? index >= activeIndex : index !== activeIndex) return null; if (!breadcrumb && index === _embeddedEvidenceIndex) return null; - return _evidenceAnchors[index] || null; + return refineEvidenceAnchor(index); }; } @@ -2448,7 +2477,12 @@ export function createBhoteKoshiEventLayer({ placement: 'above', }); } - overlayHost.setEntries(BHOTE_KOSHI_OVERLAY_SOURCE_ID, entries, { + _evidenceEntries = entries; + restoreEvidenceEntries(); + } + + function restoreEvidenceEntries() { + overlayHost.setEntries(BHOTE_KOSHI_OVERLAY_SOURCE_ID, _evidenceEntries, { cohortLimit: 16, collisionCapacity: 12, moving: false, @@ -3557,6 +3591,7 @@ export function createBhoteKoshiEventLayer({ return; } createSplitControl(_event); + restoreEvidenceEntries(); syncPresentationMode(); attachCameraInputRelease(); attachEvidenceCardActivation(); @@ -3739,6 +3774,7 @@ export function createBhoteKoshiEventLayer({ _corridorPositionCache = { visibleCount: -1, positions: [] }; _evidenceTimeline = []; _evidenceAnchors = []; + _evidenceEntries = []; _cinematicKeyframes = []; for (const slot of _evidenceFrameSlots) { closeFrame(slot.frame); diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs index 19e6aff9b..d9146e7b3 100644 --- a/src/data/bhoteKoshiEvent.test.mjs +++ b/src/data/bhoteKoshiEvent.test.mjs @@ -3133,3 +3133,182 @@ test('Bhote Koshi stops waiting for a stalled map switch when its scene is relea globalThis.window = originalWindow; } }); + +test('Bhote Koshi republishes its evidence cards to the incoming engine overlay', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + installEventDom(); + globalThis.window = { setTimeout: () => 1, clearTimeout() {} }; + const first = eventViewer(); + const second = eventViewer(); + const engineB = { + id: 'B', + host: { canvas: second.scene.canvas }, + camera: second.camera, + }; + const hosts = new Map(); + const hostFor = (engine) => { + const key = engine ?? 'A'; + if (!hosts.has(key)) + hosts.set(key, { + entries: new Map(), + setEntries(id, e) { + this.entries.set(id, e); + }, + setVisible() {}, + clearSource(id) { + this.entries.delete(id); + }, + hitTest() { + return null; + }, + }); + return hosts.get(key); + }; + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageLoader: async (url) => ({ url }), + embeddedMediaFactory: () => ({ + show() {}, + hide() {}, + pause() {}, + destroy() {}, + }), + overlayHost: hostFor, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => 'esri-imagery', + getSwitchGeneration: () => 1, + async setStack() {}, + }); + const manager = new LayerLifecycle(first); + manager.register(layer); + try { + assert.equal( + await manager.setEnabled(layer.id, true, { origin: 'local-restore' }), + true, + ); + const a = hostFor(null); + assert.ok( + [...a.entries.values()].some((e) => e.length > 0), + 'first engine got the cards', + ); + await layer.detachRenderer(); + await layer.attachRenderer(second, { engine: engineB }); + const b = hostFor(engineB); + assert.ok( + [...b.entries.values()].some((e) => e.length > 0), + 'incoming engine overlay should get the cards', + ); + } finally { + await manager.destroyLayer(layer.id); + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); + +// The event on a Navara engine double: its surface samples the terrain at +// its finest level (or fails to), and its loaded terrain answers `terrain`. +async function navaraEvidenceCard({ sample }) { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + installEventDom(); + globalThis.window = { setTimeout: () => 1, clearTimeout() {} }; + const entries = []; + const engine = { + terrain: 3500, + host: { canvas: eventViewer().scene.canvas }, + surface: { + terrainHeight: () => engine.terrain, + sampleTerrainMostDetailed: sample, + }, + maps: { terrainRevision: () => 1 }, + camera: { livePosition: Cesium.Cartesian3.fromDegrees(0, 0, 1e7) }, + }; + const layer = buildEventLayer({ + eventLoader: async () => event, + imageLoader: async (url) => ({ url }), + createNativeRenderer: () => ({ + loadImages: async () => {}, + draw: () => ({ destroy() {} }), + setVisible() {}, + setSplit() {}, + clear() {}, + destroy() {}, + }), + embeddedMediaFactory: () => ({ + show() {}, + hide() {}, + pause() {}, + destroy() {}, + }), + overlayHost: { + setEntries: (_id, value) => entries.push(...value), + setVisible() {}, + clearSource() {}, + }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + await layer.init(null, { engine }); + await layer.enable(null, { origin: 'local-restore' }); + const [first] = event.evidenceSpine; + const card = entries.find( + (entry) => entry.id === `evidence-card-${first.id}`, + ); + return { + engine, + layer, + card, + first, + height: () => Cesium.Cartographic.fromCartesian(card.cullPosition).height, + }; +} + +test('Navara evidence anchors stand on the most detailed terrain', async () => { + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const { layer, height } = await navaraEvidenceCard({ + sample: async (places) => places.map(() => 1234), + }); + try { + assert.ok(Math.abs(height() - 1252) < 0.01, `${height()}`); + } finally { + await layer.disable(); + await layer.destroy(); + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); + +test('Navara evidence anchors without a terrain sample follow the terrain near the camera', async () => { + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const { engine, layer, card, first, height } = await navaraEvidenceCard({ + sample: async () => { + throw new Error('no terrain'); + }, + }); + try { + const pack = first.fallbackElevationM + 18; + assert.ok(Math.abs(height() - pack) < 0.01, 'the pack elevation'); + card.position(); + assert.ok(Math.abs(height() - pack) < 0.01, 'a far camera keeps it'); + engine.camera.livePosition = Cesium.Cartesian3.fromDegrees( + first.lon, + first.lat, + 6000, + ); + engine.terrain = 2000; + assert.ok(card.position(), 'the first card is shown'); + assert.ok( + Math.abs(height() - 2018) < 0.01, + 'a near camera reads the terrain', + ); + } finally { + await layer.disable(); + await layer.destroy(); + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); diff --git a/src/data/cameraArrival.test.mjs b/src/data/cameraArrival.test.mjs index 544eb80c2..96d9a8055 100644 --- a/src/data/cameraArrival.test.mjs +++ b/src/data/cameraArrival.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { LayerLifecycle } from './lifecycle.js'; import { cancelCameraArrival, ownCameraArrival } from './cameraArrival.js'; test('an arrival owned on the engine yields to a cancel on the engine', () => { @@ -33,3 +34,29 @@ test('a newer arrival replaces the older one and release is idempotent', () => { cancelCameraArrival(null); assert.deepEqual(seen, ['old', 'new']); }); + +test('disabling a layer cancels an engine arrival without a native viewer', async () => { + const engine = createFakeEngine(); + const manager = new LayerLifecycle(null, { engine }); + manager.register({ + id: 'arrival', + name: 'Arrival', + init() {}, + enable() {}, + disable() {}, + update() {}, + updateInterval: -1, + }); + let cancelled = 0; + const release = ownCameraArrival(engine, () => { + cancelled++; + }); + try { + await manager.setEnabled('arrival', false); + assert.equal(cancelled, 1); + } finally { + release(); + await manager.destroyLayer('arrival'); + engine.destroy(); + } +}); diff --git a/src/data/geoFileImportDegenerate.test.mjs b/src/data/geoFileImportDegenerate.test.mjs new file mode 100644 index 000000000..b0cc9d814 --- /dev/null +++ b/src/data/geoFileImportDegenerate.test.mjs @@ -0,0 +1,35 @@ +// A file whose only geometry is degenerate (a one-point line, a two-point +// ring) draws nothing; main refused it with "That file has nothing to draw.". +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { loadGeoFile, GeoImportError } from './geoFileImport.js'; + +const geojson = (geometry) => { + const blob = new Blob([ + JSON.stringify({ type: 'Feature', properties: {}, geometry }), + ]); + blob.name = 'degenerate.geojson'; + return blob; +}; + +for (const geometry of [ + { type: 'LineString', coordinates: [[1, 2]] }, + { + type: 'Polygon', + coordinates: [ + [ + [1, 2], + [2, 3], + ], + ], + }, +]) { + test(`a ${geometry.type} with nothing to draw is refused`, async () => { + await assert.rejects( + loadGeoFile(geojson(geometry)), + (error) => + error instanceof GeoImportError && + /nothing to draw/.test(error.message), + ); + }); +} diff --git a/src/data/geoImportDisplay.js b/src/data/geoImportDisplay.js index e8823d633..78e6edd95 100644 --- a/src/data/geoImportDisplay.js +++ b/src/data/geoImportDisplay.js @@ -1,9 +1,7 @@ // One imported file (UTILITIES ▸ Import) drawn on the globe contract, the // same on every engine, from its plain records (src/data/geoImportRecords.js), // with Cesium's look as the reference: -// - polygons: one ground 'polygon' set of their fills; a raised polygon -// (KML absolute or relative) also gets its outline as lines at its -// heights, as Cesium drew it (its fill drapes: the ground has no heights); +// - polygons: ground fills, or meshes and outlines at the vertices' heights; // - ground lines: one ground 'corridor' set in pixels, over the fills; // - lines in the air: one engine line each; // - points: one sprite set per look, each sprite a square centred on its @@ -11,13 +9,16 @@ // geoImportPins.js); points on the ground draw over the scene and are // hidden beyond the horizon instead, as Cesium's clamped billboards // ignored the depth test; -// - ground overlays: one image imagery layer each over its rectangle. +// - ground overlays: one image imagery layer each over its rectangle, its +// image tinted and turned on a canvas when the KML says so, an absolute +// one raised to its altitude; a plain colour is a ground fill. // Nothing is pickable: an imported file has no card. Ground heights under // the points come from engine.surface, sampled again by refreshGround(): // the terrain where the globe is drawn, else (3D tiles) the scene drawn // under a low camera, never an imported point (as Cesium's import kept its // pins out of height queries). +import earcut from 'earcut'; import * as geo from '../geo/index.js'; import { normalizeGroundItem } from '../globe/ground.js'; import { composeIcon } from './geoImportPins.js'; @@ -34,6 +35,10 @@ const HORIZON_LIFT_M = 2000; // drawn now; higher up the terrain answers (Cesium's 250 km). const CLAMP_TILES_BELOW_M = 250_000; const BATCH = 256; +// Canvas pixels every imported icon look shares (64 MiB of RGBA). +const ICON_PIXEL_BUDGET = 16 * 1024 * 1024; +// The longest side of a tinted or turned ground overlay's canvas. +const MAX_OVERLAY_SIDE = 4096; let serial = 0; // Each engine's imported point sets, left out of each other's ground. const pointSets = new WeakMap(); @@ -60,6 +65,103 @@ function checked(kind, item) { } } +// A ground overlay's box with east past west across 180°, and how a corner +// turns by its rotation: KML's degrees counter-clockwise about the centre, +// in longitude and latitude (Cesium's rectangle rotation). +function overlayBox({ rectangle, rotation = 0 }) { + const { west, south, north } = rectangle; + const east = rectangle.east < west ? rectangle.east + 360 : rectangle.east; + const box = { west, south, east, north }; + const center = [(west + east) / 2, (south + north) / 2]; + const angle = (rotation * Math.PI) / 180; + const [cos, sin] = [Math.cos(angle), Math.sin(angle)]; + const turn = ([lon, lat]) => { + const [x, y] = [lon - center[0], lat - center[1]]; + return [center[0] + x * cos - y * sin, center[1] + x * sin + y * cos]; + }; + return { box, center, angle, turn }; +} + +// What a ground overlay's image layer draws: its image tinted by the +// overlay's colour and turned by its rotation onto the box around the +// turned image (transparent outside it). Null when that box passes a pole. +async function overlayImage(overlay, loadImage, createCanvas) { + const tint = overlay.color.slice(0, 3); + const { box, center, angle } = overlayBox(overlay); + if (tint.every((value) => value === 1) && !angle) + return { url: overlay.url, rectangle: overlay.rectangle }; + const [width, height] = [box.east - box.west, box.north - box.south]; + const [cos, sin] = [Math.abs(Math.cos(angle)), Math.abs(Math.sin(angle))]; + const [spanX, spanY] = [ + width * cos + height * sin, + width * sin + height * cos, + ]; + const rectangle = { + west: center[0] - spanX / 2, + south: center[1] - spanY / 2, + east: center[0] + spanX / 2, + north: center[1] + spanY / 2, + }; + if (rectangle.south < -90 || rectangle.north > 90 || spanX >= 360) + return null; + let image; + try { + image = await loadImage(overlay.url); + } catch { + // An image that does not decode draws nothing, as on the plain path. + return { url: overlay.url, rectangle: overlay.rectangle }; + } + const natural = [ + image.naturalWidth || image.width, + image.naturalHeight || image.height, + ]; + // Pixels per degree: one scale on both axes when turned, so the image + // turns in degrees as Cesium's does. + let [scaleX, scaleY] = [natural[0] / width, natural[1] / height]; + if (angle) scaleX = scaleY = Math.max(scaleX, scaleY); + const fit = Math.min( + 1, + MAX_OVERLAY_SIDE / (spanX * scaleX), + MAX_OVERLAY_SIDE / (spanY * scaleY), + ); + [scaleX, scaleY] = [scaleX * fit, scaleY * fit]; + const canvas = createCanvas(); + canvas.width = Math.max(1, Math.round(spanX * scaleX)); + canvas.height = Math.max(1, Math.round(spanY * scaleY)); + const context = canvas.getContext('2d'); + context.translate(canvas.width / 2, canvas.height / 2); + // The canvas's y runs south: a counter-clockwise turn is a negative one. + context.rotate(-angle); + context.drawImage( + image, + (-width * scaleX) / 2, + (-height * scaleY) / 2, + width * scaleX, + height * scaleY, + ); + if (tint.some((value) => value !== 1)) { + const pixels = context.getImageData(0, 0, canvas.width, canvas.height); + for (let i = 0; i < pixels.data.length; i += 4) + for (let channel = 0; channel < 3; channel++) + pixels.data[i + channel] *= tint[channel]; + context.putImageData(pixels, 0, 0); + } + const wrap = (lon) => (lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon); + return { + image: canvas, + rectangle: { + ...rectangle, + west: wrap(rectangle.west), + east: wrap(rectangle.east), + }, + }; +} + +// KML headings are from north (Cesium's billboard on the Earth's axis): +// the sprite turns on screen with the camera's heading. +const turnsWithCamera = (look) => look.pins.some((pin) => pin.rotation); +const onScreen = (pin, heading) => (pin.rotation ? pin.rotation + heading : 0); + const flat = (positions) => positions.map(([lon, lat]) => [lon, lat]); const cartesian = ([lon, lat, height = 0]) => geo.cartesian3.fromDegrees(lon, lat, height); @@ -110,6 +212,9 @@ export async function drawImportedFile( let removeCull = null; const groundSets = []; const lines = []; + const meshes = []; + const pins = []; + const kinds = { ...records.undrawn }; const layers = []; const looks = new Map(); let drawn = 0; @@ -120,12 +225,14 @@ export async function drawImportedFile( removeCull = null; for (const set of groundSets.splice(0)) set.destroy(); for (const line of lines.splice(0)) line.destroy(); + for (const mesh of meshes.splice(0)) mesh.destroy(); for (const layer of layers.splice(0)) layer.destroy(); for (const look of looks.values()) { pointSetsOf(engine).delete(look.set); look.set?.destroy(); } looks.clear(); + pins.length = 0; } try { @@ -133,7 +240,34 @@ export async function drawImportedFile( const fills = []; for (const polygon of records.polygons) { let any = false; - if (polygon.fill) { + if (polygon.fill && polygon.raised) { + const vertices = polygon.rings.flat(); + const origin = vertices[0]?.[0] ?? 0; + const xy = vertices.flatMap(([lon, lat]) => [ + origin + ((((lon - origin + 180) % 360) + 360) % 360) - 180, + lat, + ]); + let count = 0; + const holes = polygon.rings + .slice(0, -1) + .map((ring) => (count += ring.length)); + const indices = earcut(xy, holes); + if (indices.length) { + meshes.push( + engine.meshes.createMesh({ + id: `${prefix}-fill-${meshes.length}`, + positions: vertices.flatMap((vertex) => { + const { x, y, z } = cartesian(vertex); + return [x, y, z]; + }), + indices, + color: polygon.fill, + show: shown, + }), + ); + any = true; + } + } else if (polygon.fill) { const item = checked('polygon', { rings: polygon.rings.map(flat), fill: polygon.fill, @@ -160,34 +294,47 @@ export async function drawImportedFile( if (any) drawn++; } for (const overlay of records.overlays) { + // Cesium raises only an absolute overlay, to its altitude. + const raise = + overlay.altitudeMode === 'absolute' && overlay.altitude > 0 + ? overlay.altitude + : undefined; if (overlay.url) { + const prepared = await overlayImage(overlay, loadImage, createCanvas); + if (!prepared) { + kinds.rotatedOverlay = (kinds.rotatedOverlay ?? 0) + 1; + continue; + } layers.push( engine.imagery.createLayer({ id: `${prefix}-overlay-${layers.length}`, - source: { - kind: 'image', - url: overlay.url, - rectangle: overlay.rectangle, - }, + source: { kind: 'image', ...prepared }, // Cesium's transparent image material: at most 0.99. alpha: Math.min(0.99, overlay.color[3]), show: shown, order: OVERLAY_ORDER, + ...(raise ? { raise } : {}), }), ); drawn++; continue; } + // A plain colour draws as a ground fill. + if (raise) { + kinds.raisedOverlay = (kinds.raisedOverlay ?? 0) + 1; + continue; + } const { west, south, east, north } = overlay.rectangle; const item = checked('cell', { west, south, east, north }); if (!item) continue; + const { box, turn } = overlayBox(overlay); const rings = [ [ - [west, south], - [east < west ? east + 360 : east, south], - [east < west ? east + 360 : east, north], - [west, north], - ], + [box.west, box.south], + [box.east, box.south], + [box.east, box.north], + [box.west, box.north], + ].map(turn), ]; fills.push({ rings, fill: overlay.color }); drawn++; @@ -261,6 +408,11 @@ export async function drawImportedFile( rotation: point.icon.rotation ?? 0, }); } + pins.push(...[...looks.values()].flatMap((look) => look.pins)); + const maxSide = Math.min( + 512, + Math.floor(Math.sqrt(ICON_PIXEL_BUDGET / Math.max(1, looks.size))), + ); await Promise.all( [...looks.values()].map(async (look) => { const url = look.icon.image?.url; @@ -279,6 +431,7 @@ export async function drawImportedFile( image, createCanvas, ratio: pixelRatio, + maxSide, }); look.set = engine.movers.createSet({ id: `${prefix}-pins-${look.depth}-${looks.size}-${++serial}`, @@ -299,7 +452,11 @@ export async function drawImportedFile( }), ); if (destroyed) throw new Error('The imported file was removed'); - settle(heightContext()); + for (let from = 0; from < pins.length; from += BATCH) { + settle(heightContext(), from, from + BATCH); + if (from + BATCH < pins.length) + await new Promise((resolve) => setTimeout(resolve, 0)); + } for (const look of looks.values()) writePins(look); syncCull(); } catch (error) { @@ -316,11 +473,30 @@ export async function drawImportedFile( (engine.imagery?.surface?.() ?? 'globe') !== 'globe' && surface.clampToHeightSupported && engine.camera.height() < CLAMP_TILES_BELOW_M; - return { surface, tiles, exclude: [...pointSetsOf(engine)] }; + return { + surface, + tiles, + exclude: [...pointSetsOf(engine)], + size: tiles ? engine.host.size() : null, + }; + } + + // Whether a position is in the window now: clamping to 3D tiles reads + // the tiles drawn, and a point elsewhere would only cost the query. + function inWindow(position, { width, height }) { + if (!engine.projector.horizonVisible(position)) return false; + const screen = engine.projector.worldToWindow(position); + return ( + Boolean(screen) && + screen.x >= 0 && + screen.y >= 0 && + screen.x <= width && + screen.y <= height + ); } // The ground under a pin, falling back to the last known one. - function groundUnder(pin, { surface, tiles, exclude }) { + function groundUnder(pin, { surface, tiles, exclude, size }) { const at = geo.cartographic.fromDegrees(pin.lon, pin.lat, 0); const terrain = surface.terrainHeightSupported ? surface.terrainHeight(at) @@ -331,7 +507,10 @@ export async function drawImportedFile( ? Math.max(0, terrain) : terrain : (pin.ground ?? 0); - if (tiles) { + if ( + tiles && + inWindow(geo.cartesian3.fromDegrees(pin.lon, pin.lat, ground), size) + ) { const top = surface.clampToHeight( geo.cartesian3.fromDegrees(pin.lon, pin.lat, Math.max(0, ground)), exclude, @@ -342,33 +521,33 @@ export async function drawImportedFile( return ground; } - function settle(context, from = 0, to = Infinity) { - let at = 0; - for (const look of looks.values()) - for (const pin of look.pins) { - if (at++ < from || at > to) continue; - if (pin.height === null) pin.ground = groundUnder(pin, context); - const height = pin.height ?? pin.ground + pin.offset; - pin.position = geo.cartesian3.fromDegrees(pin.lon, pin.lat, height); - pin.horizon = geo.cartesian3.fromDegrees( - pin.lon, - pin.lat, - height + HORIZON_LIFT_M, - ); - } + function settle(context, from = 0, to = pins.length) { + for (let i = from; i < Math.min(to, pins.length); i++) { + const pin = pins[i]; + if (pin.height === null) pin.ground = groundUnder(pin, context); + const height = pin.height ?? pin.ground + pin.offset; + pin.position = geo.cartesian3.fromDegrees(pin.lon, pin.lat, height); + pin.horizon = geo.cartesian3.fromDegrees( + pin.lon, + pin.lat, + height + HORIZON_LIFT_M, + ); + } } function writePins(look) { const { set, pins } = look; if (!set) return; const culled = look.depth === 'always'; + const heading = turnsWithCamera(look) ? engine.camera.pose().heading : 0; pins.forEach((pin, index) => { if (culled) pin.show = engine.projector.horizonVisible(pin.horizon); + pin.drawnRotation = onScreen(pin, heading); set.set(index, { position: pin.position, cell: 'icon', color: pin.color, - rotation: pin.rotation, + rotation: pin.drawnRotation, show: pin.show, }); }); @@ -377,14 +556,18 @@ export async function drawImportedFile( set.commit(); } - // Hides the ground points beyond the horizon; true when any show changed. - function cull(look) { + // Hides the ground points beyond the horizon and turns the headed ones + // with the camera; true when any changed. + function cull(look, heading) { let changed = false; look.pins.forEach((pin, index) => { - const show = engine.projector.horizonVisible(pin.horizon); - if (show === pin.show) return; + const show = + look.depth !== 'always' || engine.projector.horizonVisible(pin.horizon); + const rotation = onScreen(pin, heading); + if (show === pin.show && rotation === pin.drawnRotation) return; pin.show = show; - look.set.set(index, { show }); + pin.drawnRotation = rotation; + look.set.set(index, { show, rotation }); changed = true; }); if (changed) look.set.commit(); @@ -392,33 +575,39 @@ export async function drawImportedFile( } function syncCull() { const culled = [...looks.values()].filter( - (look) => look.set && look.depth === 'always', + (look) => look.set && (look.depth === 'always' || turnsWithCamera(look)), ); if (!culled.length || !shown) { removeCull?.(); removeCull = null; return; } + const turning = culled.some(turnsWithCamera); const lastCamera = geo.cartesian3.create(Number.NaN, 0, 0); + let lastHeading = Number.NaN; removeCull ??= engine.frames.preRender.addEventListener(() => { const live = engine.camera.livePosition; - if (geo.cartesian3.equals(live, lastCamera)) return; + const heading = turning ? engine.camera.pose().heading : 0; + if (geo.cartesian3.equals(live, lastCamera) && heading === lastHeading) + return; + lastHeading = heading; geo.cartesian3.clone(live, lastCamera); let changed = false; - for (const look of culled) changed = cull(look) || changed; + for (const look of culled) changed = cull(look, heading) || changed; if (changed) request(); }); } - const undrawn = Object.values(records.undrawn).reduce((n, c) => n + c, 0); + const undrawn = Object.values(kinds).reduce((n, c) => n + c, 0); return { - report: { drawn, undrawn, kinds: { ...records.undrawn } }, + report: { drawn, undrawn, kinds }, setShow(visibleNow) { visibleNow = Boolean(visibleNow); if (destroyed || visibleNow === shown) return; shown = visibleNow; for (const set of groundSets) set.show(shown); for (const line of lines) line.show(shown); + for (const mesh of meshes) mesh.show(shown); for (const layer of layers) layer.show(shown); for (const look of looks.values()) { look.set?.setCount(shown ? look.count : 0); @@ -444,7 +633,7 @@ export async function drawImportedFile( }, /** Sample the ground under the points again (a map or the terrain changed). */ async refreshGround(signal) { - const total = [...looks.values()].reduce((n, l) => n + l.pins.length, 0); + const total = pins.length; for (let from = 0; from < total; from += BATCH) { signal?.throwIfAborted(); if (destroyed) return; diff --git a/src/data/geoImportDisplay.test.mjs b/src/data/geoImportDisplay.test.mjs index c6ae3cc5d..85c5a795a 100644 --- a/src/data/geoImportDisplay.test.mjs +++ b/src/data/geoImportDisplay.test.mjs @@ -4,7 +4,7 @@ import * as Cesium from 'cesium'; import * as geo from '../geo/index.js'; import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs'; import { drawImportedFile } from './geoImportDisplay.js'; -import { geoJsonRecords } from './geoImportRecords.js'; +import { emptyRecords, geoJsonRecords } from './geoImportRecords.js'; // A 2D context that only records what draws. const createCanvas = () => ({ @@ -191,6 +191,8 @@ for (const make of GROUND_HARNESSES) { viewer.scene.clampToHeightSupported = true; } else engine.imagery.surface = () => 'tiles'; engine.camera.height = () => 1000; + engine.projector.worldToWindow = () => ({ x: 1, y: 1 }); + engine.projector.horizonVisible = () => true; const excluded = []; engine.surface.clampToHeight = (position, exclude) => { excluded.push(exclude); @@ -270,8 +272,9 @@ for (const make of GROUND_HARNESSES) { visible: false, }); const fill = set(engine.ground.list()[0].id); - assert.equal(fill.items.length, 2); - assert.equal(fill.items[0].rings.length, 2); + assert.equal(fill.items.length, 1); + assert.equal(engine.meshes.list().length, 1); + assert.equal(fill.items[0].rings.length, 1); assert.ok(fill.items[0].rings.flat().every((p) => p.length === 2)); assert.equal(fill.shown, false); assert.equal(engine.lines.list().length, 2); @@ -339,6 +342,222 @@ for (const make of GROUND_HARNESSES) { }, ); + displayTest( + 'KML headings follow north as the camera rotates', + async ({ engine, pins }) => { + let heading = 0; + engine.camera.pose = () => ({ heading }); + const records = geoJsonRecords( + { type: 'Point', coordinates: [1, 2] }, + '#39d0ff', + ); + records.points[0].icon.rotation = -Math.PI / 4; + const drawing = await drawImportedFile(engine, records, { createCanvas }); + assert.equal(pins[0].items[0].rotation, -Math.PI / 4); + heading = Math.PI / 2; + engine.frames.preRender.raiseEvent(); + assert.equal(pins[0].items[0].rotation, Math.PI / 4); + drawing.destroy(); + }, + ); + + // A canvas whose context records the transform and holds one pixel. + const recordingCanvas = () => { + const calls = []; + const pixels = { data: new Uint8ClampedArray([128, 64, 32, 128]) }; + const context = { + translate: (...args) => calls.push(['translate', ...args]), + rotate: (angle) => calls.push(['rotate', angle]), + drawImage: (_image, ...args) => calls.push(['drawImage', ...args]), + getImageData: () => pixels, + putImageData: (value) => calls.push(['putImageData', value]), + }; + return { calls, pixels, canvas: { getContext: () => context } }; + }; + const overlayLayer = (engine) => { + const layers = []; + const create = engine.imagery.createLayer; + engine.imagery.createLayer = (options) => { + layers.push(options); + return create(options); + }; + return layers; + }; + + displayTest( + 'colored overlays multiply image RGB and retain layer alpha', + async ({ engine }) => { + const { calls, pixels, canvas } = recordingCanvas(); + const layers = overlayLayer(engine); + const records = emptyRecords(); + records.overlays.push({ + url: 'blob:overlay', + color: [1, 0, 0, 0.5], + rectangle: { west: 0, south: 0, east: 1, north: 1 }, + rotation: 0, + }); + const drawing = await drawImportedFile(engine, records, { + createCanvas: () => canvas, + loadImage: async () => ({ width: 32, height: 16 }), + }); + assert.equal(layers[0].source.image, canvas); + assert.deepEqual( + { ...layers[0].source.rectangle }, + { west: 0, south: 0, east: 1, north: 1 }, + ); + assert.equal(layers[0].alpha, 0.5); + assert.deepEqual([canvas.width, canvas.height], [32, 16]); + assert.ok( + calls.some( + ([name, value]) => name === 'putImageData' && value === pixels, + ), + ); + assert.deepEqual([...pixels.data], [128, 0, 0, 128]); + drawing.destroy(); + }, + ); + + displayTest( + 'a rotated overlay turns its image counter-clockwise onto the box around it', + async ({ engine }) => { + const { calls, canvas } = recordingCanvas(); + const layers = overlayLayer(engine); + const records = emptyRecords(); + records.overlays.push({ + url: 'blob:overlay', + color: [1, 1, 1, 1], + // Two degrees by one, across 180°, turned a quarter. + rectangle: { west: 179, south: 10, east: -179, north: 11 }, + rotation: 90, + }); + const drawing = await drawImportedFile(engine, records, { + createCanvas: () => canvas, + loadImage: async () => ({ width: 200, height: 100 }), + }); + assert.equal(drawing.report.drawn, 1); + assert.equal(drawing.report.undrawn, 0); + const { rectangle } = layers[0].source; + assert.ok(Math.abs(rectangle.west - 179.5) < 1e-9); + assert.ok(Math.abs(rectangle.east + 179.5) < 1e-9); + assert.ok(Math.abs(rectangle.south - 9.5) < 1e-9); + assert.ok(Math.abs(rectangle.north - 11.5) < 1e-9); + // 100 pixels a degree on both axes: the turned box is 1° by 2°. + assert.deepEqual([canvas.width, canvas.height], [100, 200]); + assert.deepEqual( + calls.find(([name]) => name === 'rotate'), + ['rotate', -Math.PI / 2], + ); + assert.deepEqual( + calls.find(([name]) => name === 'drawImage'), + ['drawImage', -100, -50, 200, 100], + ); + assert.equal( + calls.some(([name]) => name === 'putImageData'), + false, + ); + drawing.destroy(); + }, + ); + + displayTest( + 'a rotated overlay whose box passes a pole is reported undrawn', + async ({ engine }) => { + const records = emptyRecords(); + records.overlays.push({ + url: 'blob:overlay', + color: [1, 1, 1, 1], + rectangle: { west: 0, south: 80, east: 30, north: 89 }, + rotation: 45, + }); + const drawing = await drawImportedFile(engine, records, { createCanvas }); + assert.deepEqual(drawing.report, { + drawn: 0, + undrawn: 1, + kinds: { rotatedOverlay: 1 }, + }); + assert.equal(engine.imagery.list().length, 0); + drawing.destroy(); + }, + ); + + displayTest( + 'a rotated plain-colour overlay fills its turned corners', + async ({ engine, set }) => { + const records = emptyRecords(); + records.overlays.push({ + url: null, + color: [0, 1, 0, 0.5], + rectangle: { west: 0, south: 0, east: 2, north: 2 }, + rotation: 90, + }); + const drawing = await drawImportedFile(engine, records, { createCanvas }); + const fill = set(engine.ground.list()[0].id); + const [ring] = fill.items[0].rings; + const near = (a, b) => a.every((v, i) => Math.abs(v - b[i]) < 1e-9); + assert.ok(near(ring[0], [2, 0]), JSON.stringify(ring)); + assert.ok(near(ring[1], [2, 2]), JSON.stringify(ring)); + assert.ok(near(ring[2], [0, 2]), JSON.stringify(ring)); + drawing.destroy(); + }, + ); + + displayTest( + 'an absolute overlay is raised to its altitude, a relative one drapes', + async ({ engine }) => { + const layers = overlayLayer(engine); + const records = emptyRecords(); + for (const altitudeMode of ['absolute', 'relative']) + records.overlays.push({ + url: 'blob:overlay', + altitudeMode, + altitude: 1000, + color: [1, 1, 1, 1], + rectangle: { west: 0, south: 0, east: 1, north: 1 }, + rotation: 0, + }); + const drawing = await drawImportedFile(engine, records, { createCanvas }); + assert.deepEqual(drawing.report, { drawn: 2, undrawn: 0, kinds: {} }); + assert.equal(layers[0].raise, 1000); + assert.equal(layers[1].raise, undefined); + drawing.destroy(); + }, + ); + + displayTest( + 'tile clamps only sample visible pins and yield between batches', + async ({ engine }) => { + engine.imagery.surface = () => 'tiles'; + engine.camera.height = () => 1000; + engine.projector.horizonVisible = () => true; + let visible = false; + engine.projector.worldToWindow = () => (visible ? { x: 1, y: 1 } : null); + let clamps = 0; + engine.surface.clampToHeight = (position) => { + clamps++; + return position; + }; + const records = geoJsonRecords( + { + type: 'MultiPoint', + coordinates: Array.from({ length: 600 }, () => [1, 2]), + }, + '#39d0ff', + ); + const drawing = await drawImportedFile(engine, records, { createCanvas }); + assert.equal(clamps, 0); + visible = true; + let beforeYield; + const heartbeat = setTimeout(() => { + beforeYield = clamps; + }, 0); + await drawing.refreshGround(); + clearTimeout(heartbeat); + assert.equal(beforeYield, 256); + assert.equal(clamps, 600); + drawing.destroy(); + }, + ); + displayTest( 'an aborted ground refresh leaves the existing drawing owned', async ({ engine }) => { diff --git a/src/data/geoImportIconBudget.test.mjs b/src/data/geoImportIconBudget.test.mjs new file mode 100644 index 000000000..53e0a9f55 --- /dev/null +++ b/src/data/geoImportIconBudget.test.mjs @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { drawImportedFile } from './geoImportDisplay.js'; +import { emptyRecords } from './geoImportRecords.js'; +// A globe engine double that records what drawImportedFile asks of it. +function recordingEngine() { + const calls = { groundSets: [], lines: [], layers: [], moverSets: [] }; + const handle = (extra = {}) => ({ + destroy() {}, + show() {}, + commit() {}, + ...extra, + }); + const engine = { + calls, + render: { request() {} }, + ground: { + createSet(options) { + const rec = { options, items: [] }; + calls.groundSets.push(rec); + return handle({ replace: (items) => (rec.items = items) }); + }, + }, + lines: { + createLine(options) { + const rec = { options, positions: null }; + calls.lines.push(rec); + return handle({ setPositions: (p) => (rec.positions = p) }); + }, + }, + imagery: { + surface: () => 'globe', + createLayer(options) { + calls.layers.push(options); + return handle(); + }, + }, + movers: { + createSet(options) { + const rec = { options, items: [] }; + calls.moverSets.push(rec); + return handle({ + set: (i, f) => (rec.items[i] = { ...rec.items[i], ...f }), + setCount() {}, + }); + }, + }, + surface: { terrainHeightSupported: false, clampToHeightSupported: false }, + camera: { height: () => 1e7, livePosition: { x: 1, y: 0, z: 0 } }, + projector: { horizonVisible: () => true }, + frames: { preRender: { addEventListener: () => () => {} } }, + }; + return engine; +} +const createCanvas = () => ({ + width: 0, + height: 0, + getContext: () => + new Proxy({}, { get: (_, key) => (key in {} ? undefined : () => {}) }), +}); +const draw = (engine, records, extra = {}) => + drawImportedFile(engine, records, { + createCanvas, + loadImage: async () => ({ width: 32, height: 32 }), + pixelRatio: 2, + ...extra, + }); +const box = { west: 10, south: 10, east: 11, north: 11 }; + +test('icon variants of one file stay within a canvas budget', async () => { + const engine = recordingEngine(); + const points = []; + for (let i = 0; i < 2000; i++) + points.push({ + position: [1, 2, 0], + altitude: 'ground', + icon: { image: { url: 'blob:icon' }, scale: 10 + i / 1000 }, + }); + let bytes = 0; + await draw( + engine, + { ...emptyRecords(), points }, + { + createCanvas: () => { + const c = createCanvas(); + return new Proxy(c, { + set(target, key, value) { + if (key === 'height') bytes += target.width * value * 4; + target[key] = value; + return true; + }, + }); + }, + }, + ); + assert.ok(bytes <= 256 * 2 ** 20, `canvas bytes ${bytes / 2 ** 20} MiB`); +}); diff --git a/src/data/geoImportKml.js b/src/data/geoImportKml.js index b82768975..7fc9f8a12 100644 --- a/src/data/geoImportKml.js +++ b/src/data/geoImportKml.js @@ -380,6 +380,8 @@ function readGroundOverlay(el, records) { rectangle, color, rotation: numberOf(box, 'rotation') ?? 0, + altitude: numberOf(el, 'altitude') ?? 0, + altitudeMode: altitudeOf(el), }); } diff --git a/src/data/geoImportKml.test.mjs b/src/data/geoImportKml.test.mjs index c97aed21a..fe93d67ca 100644 --- a/src/data/geoImportKml.test.mjs +++ b/src/data/geoImportKml.test.mjs @@ -400,3 +400,15 @@ test('a KML file loads to records with its name, count and outside content remov assert.equal(loaded.records.points[0].name, 'QA KML pin'); loaded.revoke(); }); + +test('GroundOverlay retains altitude and altitude mode for drawing diagnostics', () => { + const records = readKmlRecords( + parseXml( + kml( + `1000absolute0101`, + ), + ), + ); + assert.equal(records.overlays[0].altitude, 1000); + assert.equal(records.overlays[0].altitudeMode, 'absolute'); +}); diff --git a/src/data/geoImportPins.js b/src/data/geoImportPins.js index 7866fc0fc..dcefc005c 100644 --- a/src/data/geoImportPins.js +++ b/src/data/geoImportPins.js @@ -87,7 +87,10 @@ export function iconBox(icon, natural) { * `ratio` canvas pixels per CSS pixel. Returns the canvas and the sprite's * side in CSS pixels. */ -export function composeIcon(icon, { image, createCanvas, ratio = 1 } = {}) { +export function composeIcon( + icon, + { image, createCanvas, ratio = 1, maxSide = 512 } = {}, +) { const natural = image ? { width: image.naturalWidth || image.width, @@ -96,7 +99,7 @@ export function composeIcon(icon, { image, createCanvas, ratio = 1 } = {}) { : undefined; const box = iconBox(icon, natural); // At least one canvas pixel per icon pixel, at most 512 a side. - const k = Math.min(ratio, 512 / box.side); + const k = Math.min(ratio, maxSide / box.side); const canvas = createCanvas(); canvas.width = Math.max(1, Math.ceil(box.side * k)); canvas.height = canvas.width; diff --git a/src/data/geoImportRaisedPolygon.test.mjs b/src/data/geoImportRaisedPolygon.test.mjs new file mode 100644 index 000000000..127cd051b --- /dev/null +++ b/src/data/geoImportRaisedPolygon.test.mjs @@ -0,0 +1,112 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { drawImportedFile } from './geoImportDisplay.js'; +import { emptyRecords } from './geoImportRecords.js'; +// A globe engine double that records what drawImportedFile asks of it. +function recordingEngine() { + const calls = { + meshes: [], + groundSets: [], + lines: [], + layers: [], + moverSets: [], + }; + const handle = (extra = {}) => ({ + destroy() {}, + show() {}, + commit() {}, + ...extra, + }); + const engine = { + calls, + render: { request() {} }, + meshes: { + createMesh(options) { + calls.meshes.push(options); + return handle(); + }, + }, + ground: { + createSet(options) { + const rec = { options, items: [] }; + calls.groundSets.push(rec); + return handle({ replace: (items) => (rec.items = items) }); + }, + }, + lines: { + createLine(options) { + const rec = { options, positions: null }; + calls.lines.push(rec); + return handle({ setPositions: (p) => (rec.positions = p) }); + }, + }, + imagery: { + surface: () => 'globe', + createLayer(options) { + calls.layers.push(options); + return handle(); + }, + }, + movers: { + createSet(options) { + const rec = { options, items: [] }; + calls.moverSets.push(rec); + return handle({ + set: (i, f) => (rec.items[i] = { ...rec.items[i], ...f }), + setCount() {}, + }); + }, + }, + surface: { terrainHeightSupported: false, clampToHeightSupported: false }, + camera: { height: () => 1e7, livePosition: { x: 1, y: 0, z: 0 } }, + projector: { horizonVisible: () => true }, + frames: { preRender: { addEventListener: () => () => {} } }, + }; + return engine; +} +const createCanvas = () => ({ + width: 0, + height: 0, + getContext: () => + new Proxy({}, { get: (_, key) => (key in {} ? undefined : () => {}) }), +}); +const draw = (engine, records, extra = {}) => + drawImportedFile(engine, records, { + createCanvas, + loadImage: async () => ({ width: 32, height: 32 }), + pixelRatio: 2, + ...extra, + }); +const box = { west: 10, south: 10, east: 11, north: 11 }; + +test('a raised polygon fill keeps its heights (not a ground fill)', async () => { + const engine = recordingEngine(); + await draw(engine, { + ...emptyRecords(), + polygons: [ + { + rings: [ + [ + [0, 0, 1000], + [1, 0, 1000], + [1, 1, 1000], + ], + ], + raised: true, + fill: [1, 0, 0, 1], + outline: { color: [1, 1, 1, 1], widthPx: 1 }, + }, + ], + }); + const groundFills = engine.calls.groundSets + .filter((s) => s.options.kind === 'polygon') + .flatMap((s) => s.items); + assert.equal( + groundFills.length, + 0, + 'the 1000 m fill is draped on the ground', + ); + assert.equal(engine.calls.meshes.length, 1); + assert.equal(engine.calls.meshes[0].positions[0], 6379137); + assert.equal(engine.calls.meshes[0].indices.length, 3); +}); diff --git a/src/data/geoImportRecords.js b/src/data/geoImportRecords.js index e809fcfa3..9d1853c62 100644 --- a/src/data/geoImportRecords.js +++ b/src/data/geoImportRecords.js @@ -16,7 +16,9 @@ // @property {ImportedPolygon[]} polygons // @property {ImportedOverlay[]} overlays // @property {Object} undrawn What the file has that no -// engine draws, by kind (`wall`, `extruded`, `latLonQuad`), counted. +// engine draws, by kind (`wall`, `extruded`, `latLonQuad`), counted; +// the display adds the overlays it cannot draw (`rotatedOverlay`, +// `raisedOverlay`). // // @typedef {object} ImportedPoint // @property {number[]} position @@ -62,6 +64,9 @@ // @property {{ west: number, south: number, east: number, north: number }} rectangle // @property {number[]} color Multiplies the image; its alpha fades it. // @property {number} rotation Degrees counter-clockwise (KML LatLonBox). +// @property {number} altitude Metres (KML altitude); Cesium raises an +// absolute overlay to it. +// @property {'ground'|'relative'|'absolute'} altitudeMode /** Longest plain-text name kept from a file's features. */ const MAX_IMPORT_NAME_LENGTH = 120; @@ -162,7 +167,7 @@ export function geoJsonRecords(geojson, color) { origin: 'bottom', }, }); - else if (kind === 'LineString') + else if (kind === 'LineString' && part.length >= 2) records.lines.push({ name, positions: part.map(position), @@ -170,7 +175,10 @@ export function geoJsonRecords(geojson, color) { color: stroke, widthPx: GEOJSON_STROKE_PX, }); - else if (kind === 'Polygon' && part[0]?.length) + else if ( + kind === 'Polygon' && + new Set(part[0]?.map(([lon, lat]) => `${lon},${lat}`)).size >= 3 + ) records.polygons.push({ name, rings: part.map((ring) => ring.map(position)), diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 9081b093f..31f9183b7 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -404,25 +404,6 @@ export class LayerStateCoordinator { live, enabled.has(entry.id), ); - // A same-document engine choice retains operational layer intents. - // The share still owns durable preferences and later history visits. - if ( - live && - this.dataManager.layers.get(entry.id) === live.entry && - !live.entry.lifecycleUncertain && - live.entry.enabled === targetEnabled - ) { - return { - layerId: entry.id, - targetEnabled, - origin, - phase: 'retained', - ...currentLayerOutcome(this.dataManager, entry.id), - appliedOptions: {}, - persistenceWrite: false, - succeeded: true, - }; - } const options = layerOptionsForRestore(this._durableState, entry.id); if (origin === LAYER_RESTORE_ORIGINS.share && options) { for (const trackingKey of Object.values( @@ -445,6 +426,37 @@ export class LayerStateCoordinator { succeeded: false, }; } + // A same-document engine choice retains operational layer intents. + // The share still owns durable preferences and later history visits: + // on a visit, the entry's options that differ from the live layer + // apply; a pick keeps the live layer as it is. + if ( + live && + this.dataManager.layers.get(entry.id) === live.entry && + !live.entry.lifecycleUncertain && + live.entry.enabled === targetEnabled + ) { + const current = this.dataManager.getLayerParams(entry.id); + const changed = + live.visit === true && + options && + Object.entries(options).some( + ([key, value]) => !Object.is(current?.[key], value), + ); + const succeeded = + !changed || + this.dataManager.setLayerParams(entry.id, options, { origin }); + return { + layerId: entry.id, + targetEnabled, + origin, + phase: 'retained', + ...currentLayerOutcome(this.dataManager, entry.id), + appliedOptions: changed && succeeded ? options : {}, + persistenceWrite: false, + succeeded, + }; + } // Reserve passive option state before any asynchronous lifecycle work. // A later explicit params intent then wins on its own lane without // cancelling or being overwritten by the visibility restore. diff --git a/src/data/layerStateHandoffOptions.test.mjs b/src/data/layerStateHandoffOptions.test.mjs new file mode 100644 index 000000000..c995d9c9d --- /dev/null +++ b/src/data/layerStateHandoffOptions.test.mjs @@ -0,0 +1,130 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DataLayerManager } from './manager.js'; +import { LAYER_STATE_REGISTRY, REGISTERED_LAYER_IDS } from './layerState.js'; +import { LayerStateCoordinator } from './layerStateCoordinator.js'; + +let windParamWrites = 0; +function layer(id) { + let params = + id === 'wind' + ? { model: 'gfs', overlay: 'none', units: 'km/h', paused: false } + : null; + return { + id, + name: id, + icon: '', + source: 'test', + async init() { + return true; + }, + async enable() { + return true; + }, + async update() { + return true; + }, + async disable() { + return true; + }, + attachRenderer() {}, + ...(params + ? { + setParams(next = {}) { + windParamWrites++; + params = { ...params, ...next }; + return true; + }, + getParams() { + return { ...params }; + }, + } + : {}), + }; +} +function manager() { + const m = new DataLayerManager({}); + for (const id of REGISTERED_LAYER_IDS) m.register(layer(id)); + m.finalizeRegistrations(LAYER_STATE_REGISTRY); + return m; +} +const storage = () => { + const v = new Map(); + return { getItem: (k) => v.get(k) ?? null, setItem: (k, x) => v.set(k, x) }; +}; +const sink = () => ({ setLayerStateProvider() {}, onLayerStateChange() {} }); +const cesiumEntry = { + enabledLayerIds: ['wind'], + options: { wind: { model: 'gfs' } }, +}; + +test('same-engine Back to a gfs entry restores gfs (reference)', async () => { + const m = manager(); + const c = new LayerStateCoordinator(m, sink(), { storage: storage() }); + await m.setEnabled('wind', true, { origin: 'user' }); + m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' }); + assert.equal(m.getLayerParams('wind').model, 'ifs'); + await c.applyShareLayerState({ shareLayerState: cesiumEntry }); + assert.equal(m.getLayerParams('wind').model, 'gfs'); +}); + +const handoffOf = (m) => + new Map( + [...m.layers].map(([id, entry]) => [ + id, + { entry, enabled: m.isEffectivelyEnabled(id), durable: id === 'wind' }, + ]), + ); + +test('cross-engine Back to a gfs entry restores gfs', async () => { + const m = manager(); + await m.setEnabled('wind', true, { origin: 'user' }); + m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' }); + const handoffLayers = new Map( + [...m.layers].map(([id, entry]) => [ + id, + { + entry, + enabled: m.isEffectivelyEnabled(id), + durable: id === 'wind', + visit: true, + }, + ]), + ); + const c = new LayerStateCoordinator(m, sink(), { storage: storage() }); + await c.start({ shareLayerState: cesiumEntry, handoffLayers }); + assert.equal(c.getDurableState().options.wind.model, 'gfs'); + assert.equal( + m.getLayerParams('wind').model, + 'gfs', + 'live wind should follow the visited entry', + ); +}); + +test('a picked engine keeps the live layer options, not the address', async () => { + const m = manager(); + await m.setEnabled('wind', true, { origin: 'user' }); + m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' }); + const writes = windParamWrites; + const c = new LayerStateCoordinator(m, sink(), { storage: storage() }); + await c.start({ shareLayerState: cesiumEntry, handoffLayers: handoffOf(m) }); + assert.equal(windParamWrites, writes, 'a pick writes no params'); + assert.equal(m.getLayerParams('wind').model, 'ifs'); +}); + +test('an engine switch leaves a retained layer whose options match alone', async () => { + const m = manager(); + await m.setEnabled('wind', true, { origin: 'user' }); + m.setLayerParams('wind', { model: 'gfs' }, { origin: 'user' }); + const writes = windParamWrites; + const c = new LayerStateCoordinator(m, sink(), { storage: storage() }); + const results = await c.start({ + shareLayerState: cesiumEntry, + handoffLayers: handoffOf(m), + }); + assert.equal(windParamWrites, writes, 'no params write for equal options'); + const wind = results.find((result) => result.layerId === 'wind'); + assert.equal(wind.phase, 'retained'); + assert.equal(wind.succeeded, true); + assert.deepEqual(wind.appliedOptions, {}); +}); diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 3ccb67950..f9d925e8e 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -1207,9 +1207,8 @@ export class LayerLifecycle extends LayerSceneBinding { const entry = this.layers.get(layerId); if (!entry) return { intentEpoch: null, promise: Promise.resolve() }; const desiredState = Boolean(shouldEnable); - // A layer leaving ends the camera arrival correction on a globe with a - // viewer (Cesium). - if (!desiredState && this.viewer) cancelCameraArrival(this.engine); + // A layer leaving ends its camera arrival correction. + if (!desiredState) cancelCameraArrival(this.engine); if (entry.destroying) { return { intentEpoch: null, diff --git a/src/data/trackedAircraftScenes.test.mjs b/src/data/trackedAircraftScenes.test.mjs index fe71c2096..938083088 100644 --- a/src/data/trackedAircraftScenes.test.mjs +++ b/src/data/trackedAircraftScenes.test.mjs @@ -169,10 +169,17 @@ for (const makeHarness of [fakeHarness, createCesiumMoversEngine]) { height(native.gevDisplayPosition()) < 100, 'a ready tracked model releases the billboard floor', ); + // While the model owns the visual the card sits on it, not on the + // released billboard floor below it. + const card = { ...publications.at(-1).position() }; assert.deepEqual( - publications.at(-1).position(), - follow.label.position(), - 'the card uses the follow label cache', + card, + { ...native.gevVisualPosition() }, + 'the card anchors on the tracked model', + ); + assert.ok( + height(card) >= 204.5 && height(card) < 217, + 'the card stands with the model on the sampled ground', ); const cached = { ...native.gevDisplayPosition() }; publications.at(-1).position(); @@ -185,6 +192,11 @@ for (const makeHarness of [fakeHarness, createCesiumMoversEngine]) { await frames(); const zoomedOut = layer.qaDrawn().find((row) => row.trackedModel)?.model; assert.ok(!zoomedOut?.show, 'zooming out releases the tracked model'); + assert.deepEqual( + { ...publications.at(-1).position() }, + { ...follow.label.position() }, + 'without a model the card uses the follow label cache', + ); if (layerId === 'flights') assert.ok( height(native.gevDisplayPosition()) >= 205.5, diff --git a/src/data/trackedReadout.js b/src/data/trackedReadout.js index 629d8f56f..7ffad746b 100644 --- a/src/data/trackedReadout.js +++ b/src/data/trackedReadout.js @@ -191,6 +191,7 @@ export function createTrackedReadout( return typeof model === 'function' ? model() : model; }, gevDisplayPosition: () => (handle.label ?? label).position(), + gevVisualPosition: () => handle.native?.gevVisualPosition?.(), }; _labelSubjects.set(handle, subject); } diff --git a/src/data/trackedReadout.visualAnchor.test.mjs b/src/data/trackedReadout.visualAnchor.test.mjs new file mode 100644 index 000000000..138cf9117 --- /dev/null +++ b/src/data/trackedReadout.visualAnchor.test.mjs @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createTrackedReadout } from './trackedReadout.js'; + +test('followed aircraft card anchors to the model the person sees (gevVisualPosition)', () => { + const display = { x: 1, y: 0, z: 0 }; + const visual = { x: 1, y: 0, z: 100 }; + const native = { + gevTrackedId: 'flight:abc', + gevLabelModel: { title: 'ABC123' }, + gevDisplayPosition: () => display, + gevVisualPosition: () => visual, + }; + const handle = { + native, + label: { + id: 'flight:abc', + model: () => native.gevLabelModel, + position: () => native.gevDisplayPosition(), + }, + }; + const listeners = []; + const engine = { + camera: { + following: () => handle, + followChanged: { + addEventListener: (f) => { + listeners.push(f); + return () => {}; + }, + }, + }, + }; + let entries = null; + const overlay = { + setEntries(_id, e) { + entries = e; + }, + setVisible() {}, + clearSource() { + entries = null; + }, + }; + const readout = createTrackedReadout(engine, { + overlay, + events: new EventTarget(), + selectedContext: () => null, + }); + readout.init(); + assert.ok(entries?.length === 1, 'card published'); + assert.deepEqual(entries[0].position(), visual); +}); diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js index 89579ac2b..6707976ef 100644 --- a/src/data/vesselLabels.js +++ b/src/data/vesselLabels.js @@ -51,30 +51,6 @@ export const VESSEL_TYPE_CSS_COLORS = Object.freeze([ ...new Set([...TYPE_STYLES, DEFAULT_STYLE].map(({ css }) => css)), ]); -const NUMERIC_TYPE_SPECIALS = { - 30: 'FISHING', - 31: 'TOWING', - 32: 'TOWING', - 33: 'DREDGER', - 34: 'DIVE OPS', - 35: 'MILITARY', - 36: 'SAILING', - 37: 'PLEASURE', - 50: 'PILOT', - 51: 'SAR', - 52: 'TUG', - 53: 'PORT TENDER', - 54: 'ANTI-POLLUTION', - 55: 'LAW ENFORCE', - 58: 'MEDICAL', -}; -const NUMERIC_TYPE_FAMILIES = { - 4: 'HIGH-SPEED', - 6: 'PASSENGER', - 7: 'CARGO', - 8: 'TANKER', - 9: 'OTHER', -}; // Card text for the type tokens above. The tokens stay English: TYPE_STYLES // and tests match on them. const TYPE_DISPLAY_KEYS = Object.freeze({ diff --git a/src/geo/matrix4.js b/src/geo/matrix4.js index 676b35ce6..3e312c659 100644 --- a/src/geo/matrix4.js +++ b/src/geo/matrix4.js @@ -287,12 +287,6 @@ export function getColumn(matrix, index, result) { /** The inverse of a rotation-plus-translation matrix (no scale or shear). */ export function inverseTransformation(matrix, result) { - // This function is an optimized version of the below 4 lines. - // const rT = Matrix3.transpose(Matrix4.getMatrix3(matrix)); - // const rTN = Matrix3.negate(rT); - // const rTT = Matrix3.multiplyByVector(rTN, Matrix4.getTranslation(matrix)); - // return Matrix4.fromRotationTranslation(rT, rTT, result); - const matrix0 = matrix[0]; const matrix1 = matrix[1]; const matrix2 = matrix[2]; diff --git a/src/geo/scaleToGeodeticSurface.js b/src/geo/scaleToGeodeticSurface.js index 008e6200a..e729243cc 100644 --- a/src/geo/scaleToGeodeticSurface.js +++ b/src/geo/scaleToGeodeticSurface.js @@ -99,8 +99,6 @@ export function scaleToGeodeticSurface( func = x2 * xMultiplier2 + y2 * yMultiplier2 + z2 * zMultiplier2 - 1.0; - // "denominator" here refers to the use of this expression in the velocity and acceleration - // computations in the sections to follow. denominator = x2 * xMultiplier3 * oneOverRadiiSquaredX + y2 * yMultiplier3 * oneOverRadiiSquaredY + diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js index ef8e0a762..4fa96767f 100644 --- a/src/globe/cesium/camera.js +++ b/src/globe/cesium/camera.js @@ -203,6 +203,12 @@ export function createCesiumCamera(viewer) { if (!view?.destination) throw new TypeError('setView needs a destination'); const orientation = cesiumOrientation(view.orientation); + // A view replaces a running flight, as on Navara (Cesium's own tween + // would otherwise overwrite it on the next frame). + if (flight || viewer.scene.tweens?.length > 0) { + cesium.cancelFlight(); + flight = null; + } cesium.setView({ destination: cesiumDestination(view.destination), orientation, diff --git a/src/globe/cesium/camera.test.mjs b/src/globe/cesium/camera.test.mjs index d0e5033ae..c8556a1fc 100644 --- a/src/globe/cesium/camera.test.mjs +++ b/src/globe/cesium/camera.test.mjs @@ -516,3 +516,39 @@ test('commanded leaves a newer hook on the camera in place', () => { remove(); assert.equal(cesium.flyTo, newer); }); + +test('setView cancels a running flight before placing the camera', () => { + const { camera, cesium } = setup(); + camera.flyTo({ + destination: geo.cartesian3.fromDegrees(0, 0, 50000), + duration: 2, + }); + const calls = []; + cesium.cancelFlight = () => calls.push('cancel'); + cesium.setView = () => calls.push('place'); + camera.setView({ destination: geo.cartesian3.fromDegrees(0, 0, 200000) }); + assert.deepEqual(calls, ['cancel', 'place']); +}); + +test('setView without a flight places the camera only', () => { + const { camera, cesium } = setup(); + const calls = []; + cesium.cancelFlight = () => calls.push('cancel'); + cesium.setView = () => calls.push('place'); + camera.setView({ destination: geo.cartesian3.fromDegrees(0, 0, 200000) }); + assert.deepEqual(calls, ['place']); +}); + +test('setView ends the engine flight through its cancellation callback', () => { + const { camera } = setup(); + const outcome = []; + camera.flyTo({ + destination: geo.cartesian3.fromDegrees(0, 0, 50000), + duration: 2, + cancel: () => outcome.push('cancel'), + complete: () => outcome.push('complete'), + }); + camera.setView({ destination: geo.cartesian3.fromDegrees(0, 0, 200000) }); + assert.deepEqual(outcome, ['cancel']); + assert.equal(camera.flightActive(), false); +}); diff --git a/src/globe/cesium/ground.js b/src/globe/cesium/ground.js index 82b4745f6..2cb42aec0 100644 --- a/src/globe/cesium/ground.js +++ b/src/globe/cesium/ground.js @@ -492,7 +492,7 @@ export function createCesiumGround( for (const primitive of drawn) { primitive.gevGroundSet = options.id; slot.add(primitive); - building.add(primitive); + if (slot.show) building.add(primitive); } watch(); animate(); @@ -505,6 +505,12 @@ export function createCesiumGround( }, show(visible) { slot.show = visible; + for (const primitive of drawn) { + if (visible && (!primitive.ready || !surfaceReady())) + building.add(primitive); + else building.delete(primitive); + } + if (building.size) watch(); animate(); requestRender('ground'); }, diff --git a/src/globe/cesium/ground.test.mjs b/src/globe/cesium/ground.test.mjs index 5210e9c01..00644cfe7 100644 --- a/src/globe/cesium/ground.test.mjs +++ b/src/globe/cesium/ground.test.mjs @@ -472,3 +472,30 @@ test('non-pickable ground fills skip pick commands and retain normal drawing', ( port.destroy(); assert.equal(fill.isDestroyed(), true); }); + +test('hidden unready ground stops build frames and resumes when shown', () => { + const viewer = createFakeViewer(); + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); + const requests = []; + const primitive = () => ({ + ready: false, + destroy() {}, + isDestroyed: () => false, + }); + const part = createCesiumGround(viewer, { + factories: { fills: primitive, lines: primitive }, + requestRender: (why) => requests.push(why), + }); + const set = part.ground.createSet({ id: 'hidden', kind: 'disc' }); + set.replace([{ center: [0, 0], radiusM: 100, fill: [1, 0, 0, 1] }]); + set.commit(); + set.show(false); + requests.length = 0; + viewer.scene.postRender.raiseEvent(); + assert.ok(!requests.includes('ground-build')); + set.show(true); + requests.length = 0; + viewer.scene.postRender.raiseEvent(); + assert.ok(requests.includes('ground-build')); + part.destroy(); +}); diff --git a/src/globe/cesium/imagery.js b/src/globe/cesium/imagery.js index 02a6bee48..196917a86 100644 --- a/src/globe/cesium/imagery.js +++ b/src/globe/cesium/imagery.js @@ -517,6 +517,9 @@ export function createCesiumImagery( job.collection = collection; const provider = providerFor(job, job.source); const requestImage = provider.requestImage.bind(provider); + job.restoreRequests = () => { + provider.requestImage = requestImage; + }; provider.requestImage = (x, y, level, request) => { if (job.closed) return undefined; const result = requestImage(x, y, level, request); @@ -621,6 +624,12 @@ export function createCesiumImagery( function swapIn(entry, job) { entry.staging = null; const layer = job.layer; + job.restoreRequests?.(); + if (layer) + Object.defineProperty(layer.imageryProvider, 'credit', { + configurable: true, + get: () => entry.credit, + }); job.layer = null; closeJob(job); entry.loading = null; diff --git a/src/globe/cesium/imageryStageCredit.test.mjs b/src/globe/cesium/imageryStageCredit.test.mjs new file mode 100644 index 000000000..bc17c55b0 --- /dev/null +++ b/src/globe/cesium/imageryStageCredit.test.mjs @@ -0,0 +1,162 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { TILE_RETRY_DELAY_MS, createCesiumImagery } from './imagery.js'; + +const BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 }; +const tiles = (extra = {}) => ({ + kind: 'tiles', + url: 'https://gibs-{s}.example/{z}/{y}/{x}.png', + maxLevel: 12, + rectangle: BOX, + ...extra, +}); +const pixels = (width = 4, height = 2) => ({ + width, + height, + data: new Uint8ClampedArray(width * height * 4), +}); + +/** A tile provider whose requests stay pending until the test settles them. */ +class PendingProvider { + constructor(options) { + this.options = options; + this.calls = []; + this.pending = []; + // What a Cesium ImageryLayer reads of its provider. + this.tilingScheme = + options.tilingScheme ?? new Cesium.WebMercatorTilingScheme(); + this.rectangle = options.rectangle ?? this.tilingScheme.rectangle; + this.tileWidth = this.tileHeight = 256; + this.minimumLevel = 0; + this.maximumLevel = options.maximumLevel ?? 18; + this.errorEvent = new Cesium.Event(); + } + requestImage(x, y, level) { + this.calls.push([x, y, level]); + return new Promise((resolve) => this.pending.push(resolve)); + } +} + +function manualTimers() { + const pending = new Map(); + let next = 1; + return { + pending, + setTimeout(run, ms) { + const id = next++; + pending.set(id, { run, ms }); + return id; + }, + clearTimeout: (id) => pending.delete(id), + flush() { + const entries = [...pending.values()]; + pending.clear(); + for (const entry of entries) entry.run(); + }, + }; +} + +// `base`: the globe has base imagery under the engine's layers, as the map +// gives it. +function fixture({ requestImage, base = true } = {}) { + const preRender = new Cesium.Event(); + const preUpdate = new Cesium.Event(); + const viewer = { + imageryLayers: new Cesium.ImageryLayerCollection(), + scene: { globe: { show: true }, preRender, preUpdate, splitPosition: 0.5 }, + }; + const renders = []; + const timers = manualTimers(); + const tileset = { + show: false, + imageryLayers: new Cesium.ImageryLayerCollection(), + isDestroyed: () => false, + }; + const providers = []; + const { imagery, destroy } = createCesiumImagery(viewer, { + requestRender: (reason) => renders.push(reason), + tilesets: () => [tileset], + createCanvas: (width, height) => ({ + width, + height, + getContext: () => ({ + createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }), + putImageData() {}, + }), + }), + factories: { + tiles(options) { + const provider = new PendingProvider(options); + if (requestImage) provider.requestImage = requestImage; + providers.push(provider); + return provider; + }, + }, + setTimeout: timers.setTimeout, + clearTimeout: timers.clearTimeout, + }); + const globe = viewer.imageryLayers; + const baseLayer = base + ? new Cesium.ImageryLayer(new PendingProvider({})) + : null; + if (baseLayer) globe.add(baseLayer); + // A frame: preUpdate, then (Cesium picks the base layer) preRender. + const frame = () => { + preUpdate.raiseEvent(); + preRender.raiseEvent(); + }; + return { + base: baseLayer, + viewer, + imagery, + destroy, + globe, + tileset, + providers, + renders, + timers, + frame, + tileFrames: () => renders.filter((r) => r === 'imagery-tiles').length, + }; +} + +const layersOf = (collection) => + Array.from({ length: collection.length }, (_, i) => collection.get(i)); +/** The layers on the globe over the fixture's base imagery. */ +const drawnOn = (f) => layersOf(f.globe).filter((layer) => layer !== f.base); + +// A fixture whose scene draws frames: postRender, a globe whose tiles load. +function stagingFixture() { + const f = fixture(); + f.viewer.scene.postRender = new Cesium.Event(); + f.viewer.scene.globe.tilesLoaded = true; + f.render = () => f.viewer.scene.postRender.raiseEvent(); + return f; +} + +async function swapped(f, layer, extra = {}) { + const staged = layer.setSource( + tiles({ url: 'https://next.example/{z}/{x}/{y}.png', ...extra }), + { staged: true }, + ); + await new Promise((resolve) => setImmediate(resolve)); + const incoming = drawnOn(f)[1]; + const request = incoming.imageryProvider.requestImage(0, 0, 1); + f.providers[1].pending[0]('tile'); + await request; + f.render(); + f.render(); + assert.equal(await staged, true); + return incoming; +} + +test('after a staged swap a credit-only change reaches the drawn provider', async () => { + const f = stagingFixture(); + const layer = f.imagery.createLayer({ id: 'a', source: tiles() }); + const incoming = await swapped(f, layer, { credit: 'Old' }); + layer.setSource( + tiles({ url: 'https://next.example/{z}/{x}/{y}.png', credit: 'New' }), + ); + assert.equal(incoming.imageryProvider.credit?.html, 'New'); +}); diff --git a/src/globe/cesium/imageryStageRequests.test.mjs b/src/globe/cesium/imageryStageRequests.test.mjs new file mode 100644 index 000000000..3c9d11fff --- /dev/null +++ b/src/globe/cesium/imageryStageRequests.test.mjs @@ -0,0 +1,162 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { TILE_RETRY_DELAY_MS, createCesiumImagery } from './imagery.js'; + +const BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 }; +const tiles = (extra = {}) => ({ + kind: 'tiles', + url: 'https://gibs-{s}.example/{z}/{y}/{x}.png', + maxLevel: 12, + rectangle: BOX, + ...extra, +}); +const pixels = (width = 4, height = 2) => ({ + width, + height, + data: new Uint8ClampedArray(width * height * 4), +}); + +/** A tile provider whose requests stay pending until the test settles them. */ +class PendingProvider { + constructor(options) { + this.options = options; + this.calls = []; + this.pending = []; + // What a Cesium ImageryLayer reads of its provider. + this.tilingScheme = + options.tilingScheme ?? new Cesium.WebMercatorTilingScheme(); + this.rectangle = options.rectangle ?? this.tilingScheme.rectangle; + this.tileWidth = this.tileHeight = 256; + this.minimumLevel = 0; + this.maximumLevel = options.maximumLevel ?? 18; + this.errorEvent = new Cesium.Event(); + } + requestImage(x, y, level) { + this.calls.push([x, y, level]); + return new Promise((resolve) => this.pending.push(resolve)); + } +} + +function manualTimers() { + const pending = new Map(); + let next = 1; + return { + pending, + setTimeout(run, ms) { + const id = next++; + pending.set(id, { run, ms }); + return id; + }, + clearTimeout: (id) => pending.delete(id), + flush() { + const entries = [...pending.values()]; + pending.clear(); + for (const entry of entries) entry.run(); + }, + }; +} + +// `base`: the globe has base imagery under the engine's layers, as the map +// gives it. +function fixture({ requestImage, base = true } = {}) { + const preRender = new Cesium.Event(); + const preUpdate = new Cesium.Event(); + const viewer = { + imageryLayers: new Cesium.ImageryLayerCollection(), + scene: { globe: { show: true }, preRender, preUpdate, splitPosition: 0.5 }, + }; + const renders = []; + const timers = manualTimers(); + const tileset = { + show: false, + imageryLayers: new Cesium.ImageryLayerCollection(), + isDestroyed: () => false, + }; + const providers = []; + const { imagery, destroy } = createCesiumImagery(viewer, { + requestRender: (reason) => renders.push(reason), + tilesets: () => [tileset], + createCanvas: (width, height) => ({ + width, + height, + getContext: () => ({ + createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }), + putImageData() {}, + }), + }), + factories: { + tiles(options) { + const provider = new PendingProvider(options); + if (requestImage) provider.requestImage = requestImage; + providers.push(provider); + return provider; + }, + }, + setTimeout: timers.setTimeout, + clearTimeout: timers.clearTimeout, + }); + const globe = viewer.imageryLayers; + const baseLayer = base + ? new Cesium.ImageryLayer(new PendingProvider({})) + : null; + if (baseLayer) globe.add(baseLayer); + // A frame: preUpdate, then (Cesium picks the base layer) preRender. + const frame = () => { + preUpdate.raiseEvent(); + preRender.raiseEvent(); + }; + return { + base: baseLayer, + viewer, + imagery, + destroy, + globe, + tileset, + providers, + renders, + timers, + frame, + tileFrames: () => renders.filter((r) => r === 'imagery-tiles').length, + }; +} + +const layersOf = (collection) => + Array.from({ length: collection.length }, (_, i) => collection.get(i)); +/** The layers on the globe over the fixture's base imagery. */ +const drawnOn = (f) => layersOf(f.globe).filter((layer) => layer !== f.base); + +// A fixture whose scene draws frames: postRender, a globe whose tiles load. +function stagingFixture() { + const f = fixture(); + f.viewer.scene.postRender = new Cesium.Event(); + f.viewer.scene.globe.tilesLoaded = true; + f.render = () => f.viewer.scene.postRender.raiseEvent(); + return f; +} + +async function swapped(f, layer, extra = {}) { + const staged = layer.setSource( + tiles({ url: 'https://next.example/{z}/{x}/{y}.png', ...extra }), + { staged: true }, + ); + await new Promise((resolve) => setImmediate(resolve)); + const incoming = drawnOn(f)[1]; + const request = incoming.imageryProvider.requestImage(0, 0, 1); + f.providers[1].pending[0]('tile'); + await request; + f.render(); + f.render(); + assert.equal(await staged, true); + return incoming; +} + +test('after a staged swap the drawn provider still loads new tiles', async () => { + const f = stagingFixture(); + const layer = f.imagery.createLayer({ id: 'a', source: tiles() }); + const incoming = await swapped(f, layer); + f.render(); + assert.deepEqual(drawnOn(f), [incoming]); + const next = incoming.imageryProvider.requestImage(5, 3, 4); + assert.ok(next instanceof Promise, 'a new tile after the swap is requested'); +}); diff --git a/src/globe/cesium/maps.js b/src/globe/cesium/maps.js index 96cd56582..7184aaaea 100644 --- a/src/globe/cesium/maps.js +++ b/src/globe/cesium/maps.js @@ -97,6 +97,12 @@ export function createCesiumMaps( let currentTiles = null; let creditOwner = null; let destroyed = false; + // Every terrain the globe takes counts, a fallback to the ellipsoid too. + let terrainRevision = 0; + const offTerrain = + viewer.scene.globe?.terrainProviderChanged?.addEventListener( + () => terrainRevision++, + ); const startupTileset = () => (typeof tilesetRef === 'function' ? tilesetRef() : tilesetRef?.current) ?? @@ -214,6 +220,7 @@ export function createCesiumMaps( }, photorealActive: () => tilesets().some((tileset) => tileset.show === true), current: () => ({ baseImagery: currentBase, photoreal: currentTiles }), + terrainRevision: () => terrainRevision, }; return { @@ -224,6 +231,7 @@ export function createCesiumMaps( if (destroyed) return; destroyed = true; abort.abort(); + offTerrain?.(); mapScene.destroy(); for (const promise of providers.values()) void promise.then(dispose, () => {}); diff --git a/src/globe/cesium/maps.test.mjs b/src/globe/cesium/maps.test.mjs index c15ecf6ba..90696be9d 100644 --- a/src/globe/cesium/maps.test.mjs +++ b/src/globe/cesium/maps.test.mjs @@ -201,6 +201,19 @@ test('setTerrain applies provider and terrain results', async () => { ); }); +test('terrainRevision counts every terrain the globe takes', async () => { + const { viewer, maps, destroy } = setup(); + assert.equal(maps.terrainRevision(), 0); + await maps.setTerrain({ kind: 'ellipsoid' }); + assert.equal(maps.terrainRevision(), 1); + // A fallback applied by other code counts too. + viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider(); + assert.equal(maps.terrainRevision(), 2); + destroy(); + viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider(); + assert.equal(maps.terrainRevision(), 2, 'not counted after destroy'); +}); + test('setPhotorealTiles shows a given tileset and hides the globe; null reverses it', async () => { const { viewer, maps } = setup(); const google = tileset(); diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js index f2d4d81fc..c5b6111e9 100644 --- a/src/globe/cesium/movers.js +++ b/src/globe/cesium/movers.js @@ -150,6 +150,7 @@ function spritePart(scene, options, store, ports) { } } }; + let atlasFailed = false; // Instances wait for the atlas; then everything written so far is applied. factories .cropCells(sprite.atlas, store.cellKeys) @@ -159,10 +160,14 @@ function spritePart(scene, options, store, ports) { apply(0, Math.max(store.count, billboards.length)); requestRender('movers'); }) - .catch((error) => console.warn(`[movers] ${id}: atlas failed`, error)); + .catch((error) => { + atlasFailed = true; + console.warn(`[movers] ${id}: atlas failed`, error); + }); return { primitive: collection, ready: () => cells !== null, + failed: () => atlasFailed, commit(range) { if (cells && range) apply(range.from, range.to); requestRender('movers'); @@ -226,6 +231,8 @@ function modelPart(options, store, { factories, requestRender, owners }) { const { model, pickable } = options; const models = []; const loading = []; + // Instances whose model failed: not tried again (MoverSet.failed). + const failed = []; let destroyed = false; const place = (item, index) => { const { orientations } = store; @@ -282,9 +289,11 @@ function modelPart(options, store, { factories, requestRender, owners }) { collection.add(item); requestRender('movers'); }) - .catch((error) => - console.warn(`[movers] ${options.id}: model failed`, error), - ); + .catch((error) => { + if (destroyed) return; + failed[index] = true; + console.warn(`[movers] ${options.id}: model failed`, error); + }); }; return { primitive: collection, @@ -296,6 +305,11 @@ function modelPart(options, store, { factories, requestRender, owners }) { if (!models[at]?.ready) return false; return true; }, + failed(index) { + if (index !== undefined) return failed[index] === true; + for (let at = 0; at < store.count; at++) if (failed[at]) return true; + return false; + }, commit(range) { if (range) for (let index = range.from; index < range.to; index++) { @@ -308,6 +322,7 @@ function modelPart(options, store, { factories, requestRender, owners }) { destroyed = true; models.length = 0; loading.length = 0; + failed.length = 0; }, }; } @@ -360,6 +375,7 @@ export function createCesiumMovers( return { primitive, ready: part.ready, + failed: part.failed, commit: () => part.commit(store.dirty()), destroy() { part.destroy(); diff --git a/src/globe/cesium/movers.test.mjs b/src/globe/cesium/movers.test.mjs index 03406cbf8..bdc8cf43d 100644 --- a/src/globe/cesium/movers.test.mjs +++ b/src/globe/cesium/movers.test.mjs @@ -412,6 +412,36 @@ test('a model that loads after its set is destroyed is released', async () => { assert.equal(root().length, 0); }); +test('a model that fails to load marks its instance failed, once', async (t) => { + t.mock.method(console, 'warn', () => {}); + let loads = 0; + const { movers } = setup({ + loadModel: async () => { + loads++; + throw new Error('404'); + }, + }); + const set = movers.createSet({ + id: 'broken', + kind: 'model', + capacity: 1, + model: { url: '/a.glb' }, + }); + set.set(0, { position: at(0, 0) }); + set.setCount(1); + set.commit(); + assert.equal(set.failed(), false, 'still loading'); + await settle(); + assert.equal(set.failed(0), true); + assert.equal(set.failed(), true); + assert.equal(set.ready(), false); + set.commit(); + await settle(); + assert.equal(loads, 1, 'not tried again'); + set.destroy(); + assert.equal(set.failed(), false, 'a destroyed set reports nothing'); +}); + test('identify names the set, instance and pickId of a picked billboard or point', async () => { const { movers, root, identify, viewer } = setup(); // The fake camera is over (0°, ~1°), 7000 km from the centre. diff --git a/src/globe/cesium/render.js b/src/globe/cesium/render.js index 62ee70048..51f72a5d0 100644 --- a/src/globe/cesium/render.js +++ b/src/globe/cesium/render.js @@ -1,5 +1,6 @@ // Cesium owns its render mode and scene effects. Scene services drive this API. +import * as Cesium from 'cesium'; import { CONTACT_SONAR_INITIAL, mergeContactSonar } from '../contract.js'; import { createCesiumPostStages } from './postStages.js'; import { createCesiumSonar } from './sonar.js'; @@ -58,6 +59,7 @@ export function createCesiumRender( viewer.scene.requestRenderMode = !enabled; }, isContinuous: () => viewer.scene.requestRenderMode === false, + maxTextureSize: () => Cesium.ContextLimits.maximumTextureSize, ...effects.render, setContactSonar(options) { const next = mergeContactSonar(contactSonar, options); diff --git a/src/globe/cesium/surface.js b/src/globe/cesium/surface.js index a17ef5132..d08617179 100644 --- a/src/globe/cesium/surface.js +++ b/src/globe/cesium/surface.js @@ -98,6 +98,23 @@ export function createCesiumSurface( get terrainHeightSupported() { return Boolean(scene.globe); }, + async sampleTerrainMostDetailed(positions) { + const provider = scene.terrainProvider; + if (!provider || provider instanceof Cesium.EllipsoidTerrainProvider) + return positions.map(() => 0); + // Cesium reads the finest level per position from it. + if (!provider.availability) + throw new Error('The terrain has no tile availability'); + const sampled = await Cesium.sampleTerrainMostDetailed( + provider, + positions.map( + (place) => new Cesium.Cartographic(place.longitude, place.latitude), + ), + ); + return sampled.map(({ height }) => + Number.isFinite(height) ? height : undefined, + ); + }, clampToHeight(position, exclude) { if (!scene.clampToHeightSupported) return undefined; try { diff --git a/src/globe/cesium/surface.test.mjs b/src/globe/cesium/surface.test.mjs index 015ac8488..a7bac90d1 100644 --- a/src/globe/cesium/surface.test.mjs +++ b/src/globe/cesium/surface.test.mjs @@ -59,6 +59,21 @@ test('terrainHeight asks the globe', () => { assert.equal(surface.terrainHeight(place), undefined); }); +test('sampleTerrainMostDetailed: 0 on the ellipsoid; a terrain without availability rejects', async () => { + const { surface, scene } = setup(); + const place = geo.cartographic.fromDegrees(85.9, 27.9, 0); + scene.terrainProvider = new Cesium.EllipsoidTerrainProvider(); + assert.deepEqual( + await surface.sampleTerrainMostDetailed([place, place]), + [0, 0], + ); + scene.terrainProvider = { availability: undefined }; + await assert.rejects( + surface.sampleTerrainMostDetailed([place]), + /no tile availability/, + ); +}); + test('clampToHeight returns a geo value', () => { const { viewer, surface } = setup(); const exclude = []; diff --git a/src/globe/cesium/testing/fakeViewer.mjs b/src/globe/cesium/testing/fakeViewer.mjs index 8a3fc4548..befefa7b4 100644 --- a/src/globe/cesium/testing/fakeViewer.mjs +++ b/src/globe/cesium/testing/fakeViewer.mjs @@ -125,11 +125,13 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) { ), ), }; + let terrainProvider; const globe = { show: true, tilesLoaded: true, getHeight: record('globe.getHeight', () => 12.5), pick: record('globe.pick', () => new Cesium.Cartesian3(6378137, 3, 4)), + terrainProviderChanged: new Cesium.Event(), }; const scene = { camera, @@ -222,7 +224,14 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) { currentTime: Cesium.JulianDate.fromIso8601('2026-10-16T12:00:00Z'), }, imageryList, - terrainProvider: undefined, + // As Viewer's: the globe takes it and says so. + get terrainProvider() { + return terrainProvider; + }, + set terrainProvider(provider) { + terrainProvider = provider; + globe.terrainProviderChanged.raiseEvent(provider); + }, imageryLayers: { add: record('imageryLayers.add', (layer, index) => imageryList.splice(index ?? imageryList.length, 0, layer), diff --git a/src/globe/contract.js b/src/globe/contract.js index 89b6646eb..4a73c5778 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -92,7 +92,7 @@ * @property {() => number} height The camera's height above the WGS84 * ellipsoid (the pose's cartographic height), without building a pose. * @property {(view: { destination: Cartesian3 | Rectangle, orientation?: Orientation }) => void} setView - * Missing angles default to heading 0, pitch −π/2, roll 0. + * Cancels a running flight. Missing angles default to heading 0, pitch −π/2, roll 0. * @property {(options: FlightOptions) => void} flyTo Cancels a running flight first. * @property {(sphere: { center: Cartesian3, radius: number }, options?: SphereFlightOptions) => void} flyToSphere * Ends unlocked (identity transform). @@ -214,7 +214,13 @@ * @property {(cartographic: Cartographic) => (number | undefined)} terrainHeight * Height of the terrain/globe surface only (Cesium's globe.getHeight). * @property {boolean} terrainHeightSupported - * @property {(position: Cartesian3, exclude?: any[]) => (Cartesian3 | undefined)} clampToHeight + * @property {(positions: Cartographic[]) => Promise<(number | undefined)[]>} sampleTerrainMostDetailed + * Terrain heights (as terrainHeight) at the terrain's finest level, + * fetched where it is not loaded (Cesium's sampleTerrainMostDetailed), one + * per position, in order: undefined where no tile answers, 0 on the + * ellipsoid. Rejects when the terrain cannot be sampled (Cesium: one + * without tile availability). The terrain is the one shown at the call; + * maps.terrainRevision tells when it changed since. * The position moved onto the rendered scene along the geodetic normal. * @property {boolean} clampToHeightSupported * @property {() => boolean} tilesLoaded @@ -237,6 +243,9 @@ * Owns whatever the engine needs to render every frame or only on request * (Cesium: requestRenderMode and maximumRenderTimeChange = Infinity). * @property {() => boolean} isContinuous + * @property {() => number} maxTextureSize The longest texture side (px) the + * GPU takes (Cesium's ContextLimits.maximumTextureSize, three's + * capabilities.maxTextureSize): a layer sizes its images under it. * @property {(options: { enabled?: boolean, amount?: number }) => void} setSharpen * An unsharp mask on the final image, after tone mapping and before * anti-aliasing: each pixel moves away from its 3×3 box blur by `amount` @@ -368,6 +377,9 @@ * @property {() => { baseImagery: object | null, photoreal: object | null }} current * The source objects on screen, as passed: the base imagery layer on the * globe and the photoreal tiles shown (null for none). + * @property {() => number} terrainRevision Changes whenever the terrain the + * globe draws is replaced (a setTerrain applied, a fallback to the + * ellipsoid): heights cached against the terrain are stale once it does. * * @typedef {object} GlobeClock * The app's own time (src/time/clock.js), in milliseconds since the Unix @@ -457,6 +469,9 @@ * sprite set's atlas. With an index, what that instance draws (Cesium: its * own model; the other engines load a set's glTF once). * A layer keeps its fallback visual until then. + * @property {(index?: number) => boolean} failed Whether what the set draws + * failed to load (with an index, what that instance draws): it stays not + * ready and is not tried again; a layer that retries makes a new set. * @property {() => void} destroy * * @typedef {object} GlobeMovers @@ -527,8 +542,7 @@ * with a pickId); false by default: picks pass through to what lies under. * @property {'both'|'terrain'|'tiles'} [drape] What the fills drape on: * terrain and 3D tiles ('both', the default), or only one of them - * (Cesium's classificationType). Navara drapes on whatever surface it - * draws. + * (Cesium's classificationType). * * @typedef {object} GroundRadial * @property {number} fillAlpha Interior alpha multiplier, 0..1. @@ -679,7 +693,7 @@ * differs only in its credit keeps its pixels. `staged`: the drawn * source stays until the new one is complete (an image loaded and * uploaded; the tiles in view loaded — Cesium: its requests in and quiet, - * at alpha 0 under the drawn one; Navara: its own atlas), then replaces + * at alpha 0 above the drawn one; Navara: its own atlas), then replaces * it; resolves true then, false when it failed (an image or a tile that * would not load), was aborted by `signal`, or was replaced by another * setSource first. A hidden layer takes a staged source at once. @@ -1001,6 +1015,7 @@ const SHAPE = { request: isFunction, setContinuous: isFunction, isContinuous: isFunction, + maxTextureSize: isFunction, setSharpen: isFunction, sharpen: isFunction, }, @@ -1010,6 +1025,7 @@ const SHAPE = { supported: isBoolean, terrainHeight: isFunction, terrainHeightSupported: isBoolean, + sampleTerrainMostDetailed: isFunction, clampToHeight: isFunction, clampToHeightSupported: isBoolean, tilesLoaded: isFunction, @@ -1049,6 +1065,7 @@ const SHAPE = { releasePhotorealTiles: isFunction, photorealActive: isFunction, current: isFunction, + terrainRevision: isFunction, }, }; diff --git a/src/globe/ground.js b/src/globe/ground.js index 78c961b76..cfa2628a8 100644 --- a/src/globe/ground.js +++ b/src/globe/ground.js @@ -602,13 +602,28 @@ const GROUND_PICK_PX = 3; * @param {{ pickGlobe: (at: object) => (object | undefined), * camera: { livePosition: object, pose: () => { fovy: number } }, * size: () => { width: number, height: number }, - * sets: () => { options: object, items: object[], shown: boolean }[] }} ports + * sets: () => { options: object, items: object[], shown: boolean }[], + * surface?: () => 'terrain' | 'tiles' }} ports `surface`: what the ground + * is now (3D tiles while they show); a set draped only on the other one + * draws nothing there and is not picked. * @returns {(at: { x: number, y: number }) => { onTop: number, order: number, depth: number, result: object }[]} */ -export function createGroundPicks({ pickGlobe, camera, size, sets }) { +export function createGroundPicks({ + pickGlobe, + camera, + size, + sets, + surface = () => 'terrain', +}) { return (at) => { + const under = surface(); const candidates = sets().filter( - (state) => state.shown && state.options.pickable && state.items.length, + (state) => + state.shown && + state.options.pickable && + state.items.length && + (state.options.drape ?? 'both') !== + (under === 'tiles' ? 'terrain' : 'tiles'), ); if (!candidates.length) return []; const world = pickGlobe(at); diff --git a/src/globe/ground.test.mjs b/src/globe/ground.test.mjs index 18e702f27..5dcc40303 100644 --- a/src/globe/ground.test.mjs +++ b/src/globe/ground.test.mjs @@ -394,6 +394,44 @@ test('CPU picks find the item under the picked ground, on top first', () => { assert.deepEqual(ids(), []); }); +test('CPU picks skip a set draped only on the other surface', () => { + const { ground, live } = createGroundRegistry(() => ({ + commit() {}, + show() {}, + destroy() {}, + })); + for (const drape of ['terrain', 'tiles', 'both']) { + const set = ground.createSet({ + id: drape, + kind: 'disc', + pickable: true, + drape, + }); + set.replace([ + { center: [1, 1], radiusM: 1e4, fill: [1, 1, 1, 1], pickId: drape }, + ]); + set.commit(); + } + let under = 'terrain'; + const picks = createGroundPicks({ + pickGlobe: () => geo.cartesian3.fromDegrees(1, 1), + camera: { + livePosition: geo.cartesian3.fromDegrees(1, 1, 1e5), + pose: () => ({ fovy: Math.PI / 3 }), + }, + size: () => ({ width: 1000, height: 1000 }), + sets: () => [...live.values()], + surface: () => under, + }); + const ids = () => + picks({ x: 0, y: 0 }) + .map(({ result }) => result.id) + .sort(); + assert.deepEqual(ids(), ['both', 'terrain']); + under = 'tiles'; + assert.deepEqual(ids(), ['both', 'tiles']); +}); + test('radial discs copy and validate their alpha envelope', () => { const radial = { fillAlpha: 0.105, diff --git a/src/globe/imagery.js b/src/globe/imagery.js index f7a1ab11a..aa2efab20 100644 --- a/src/globe/imagery.js +++ b/src/globe/imagery.js @@ -394,9 +394,11 @@ export function createImageryRegistry( if (options.raise === null) fail('only a raised layer takes a detail'); const detail = normalizeImageryDetail(next ?? null); + const pending = detailing !== null; dropDetail(); if (!detail) { - if (state.detail === null) return Promise.resolve(true); + // A detail still loading is cleared too: the backend may draw it. + if (state.detail === null && !pending) return Promise.resolve(true); state.detail = null; part.detail?.(null, () => {}); return Promise.resolve(true); @@ -504,12 +506,23 @@ export const geographicY = (lat) => */ export function imageryTilePlan(box, rectangle, level, scheme = 'webMercator') { const geographic = scheme === 'geographic'; - let area = { ...box }; - if (rectangle && rectangle.east >= rectangle.west) { + const sourceEast = box.east < box.west ? box.east + 360 : box.east; + let area = { ...box, east: sourceEast }; + if (rectangle) { const degrees = 180 / Math.PI; + const west = rectangle.west * degrees; + const east = + rectangle.east * degrees + (rectangle.east < rectangle.west ? 360 : 0); + const spans = [-360, 0, 360] + .map((shift) => [ + Math.max(box.west + shift, west), + Math.min(sourceEast + shift, east), + ]) + .filter(([left, right]) => left < right); + if (!spans.length) return null; area = { - west: Math.max(box.west, rectangle.west * degrees), - east: Math.min(box.east, rectangle.east * degrees), + west: Math.min(...spans.map(([left]) => left)), + east: Math.max(...spans.map(([, right]) => right)), south: Math.max(box.south, rectangle.south * degrees), north: Math.min(box.north, rectangle.north * degrees), }; @@ -522,10 +535,7 @@ export function imageryTilePlan(box, rectangle, level, scheme = 'webMercator') { const rows = 2 ** z; const x = Math.max(0, Math.floor(toX(area.west) * columns)); const y = Math.max(0, Math.floor(toY(area.north) * rows)); - const right = Math.min( - columns - 1, - Math.ceil(toX(area.east) * columns) - 1, - ); + const right = Math.ceil(toX(area.east) * columns) - 1; const bottom = Math.min(rows - 1, Math.ceil(toY(area.south) * rows) - 1); const width = right - x + 1, height = bottom - y + 1; diff --git a/src/globe/imageryAntimeridian.test.mjs b/src/globe/imageryAntimeridian.test.mjs new file mode 100644 index 000000000..212241441 --- /dev/null +++ b/src/globe/imageryAntimeridian.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { imageryTilePlan } from './imagery.js'; + +const r = Math.PI / 180; + +test('a view across the antimeridian plans the tiles it shows, not the whole source', () => { + // A view over Fiji, one degree wide across 180°. + const view = { + west: 179.5 * r, + east: -179.5 * r, + south: -17 * r, + north: -16 * r, + }; + const global = { west: -180, east: 180, south: -85, north: 85 }; + const plan = imageryTilePlan(global, view, 10); + // The same view one degree west (not crossing) plans level 10. + assert.ok( + plan.z >= 9, + `planned level ${plan.z}, a ${plan.width}×${plan.height} mosaic`, + ); +}); + +test('antimeridian plans intersect each side of the source in both tilings', () => { + const view = { + west: 179.5 * r, + east: -179.5 * r, + south: -17 * r, + north: -16 * r, + }; + for (const scheme of ['webMercator', 'geographic']) { + const columns = 2 ** (10 + (scheme === 'geographic' ? 1 : 0)); + const global = { west: -180, east: 180, south: -85, north: 85 }; + const plan = imageryTilePlan(global, view, 10, scheme); + assert.equal(plan.z, 10); + assert.ok(plan.x < columns && plan.x + plan.width > columns); + const west = imageryTilePlan({ ...global, west: 179 }, view, 10, scheme); + assert.ok(west.x + west.width <= columns); + const east = imageryTilePlan({ ...global, east: -179 }, view, 10, scheme); + assert.ok(east.x >= columns); + assert.equal( + imageryTilePlan({ ...global, west: 0, east: 10 }, view, 10, scheme), + null, + ); + } +}); diff --git a/src/globe/imageryDetailCancellation.test.mjs b/src/globe/imageryDetailCancellation.test.mjs new file mode 100644 index 000000000..4f10b3b30 --- /dev/null +++ b/src/globe/imageryDetailCancellation.test.mjs @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createImageryRegistry } from './imagery.js'; + +const image = { width: 2, height: 2, data: new Uint8ClampedArray(16) }; + +test('clearing a detail still loading clears it on the engine too', async () => { + const details = []; + const { imagery } = createImageryRegistry(() => ({ + source() {}, + detail: (detail, finish) => details.push([detail, finish]), + look() {}, + destroy() {}, + })); + const raised = imagery.createLayer({ + id: 'shell', + source: { kind: 'image', image }, + raise: 6000, + }); + const rectangle = { west: 1, south: 1, east: 2, north: 2 }; + // The engine already holds the detail (Cesium's shell takes it at once); + // it has not drawn yet. + const first = raised.setDetail({ image, rectangle }); + assert.equal(await raised.setDetail(null), true); + assert.equal(await first, false); + assert.equal(raised.detail(), null); + assert.equal(details.at(-1)[0], null, 'the engine is told to clear it'); + // Nothing shown or loading: another clear asks nothing of the engine. + const calls = details.length; + assert.equal(await raised.setDetail(null), true); + assert.equal(details.length, calls); +}); diff --git a/src/globe/movers.js b/src/globe/movers.js index 02a0bdacb..e4ece8af8 100644 --- a/src/globe/movers.js +++ b/src/globe/movers.js @@ -347,7 +347,7 @@ export function createMoverStore(options) { */ export function createMoverSet( store, - { id, commit, destroy, ready = () => true }, + { id, commit, destroy, ready = () => true, failed = () => false }, ) { let destroyed = false; const alive = () => { @@ -373,6 +373,8 @@ export function createMoverSet( // Whether what it draws has loaded (a model's glTF, an atlas image); with // an index, what that instance draws. ready: (index) => !destroyed && ready(index) === true, + // Whether that load failed for good (the set is not tried again). + failed: (index) => !destroyed && failed(index) === true, destroy() { if (destroyed) return; destroyed = true; @@ -409,6 +411,7 @@ export function createMoverRegistry(build) { part.destroy(); }, ready: (index) => (part.ready ? part.ready(index) : true), + failed: (index) => part.failed?.(index) === true, }); sets.set(options.id, entry); return entry.set; diff --git a/src/globe/navara/cameraChanged.test.mjs b/src/globe/navara/cameraChanged.test.mjs new file mode 100644 index 000000000..431791e5a --- /dev/null +++ b/src/globe/navara/cameraChanged.test.mjs @@ -0,0 +1,141 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../geo/index.js'; +import { isEngineEvent } from '../contract.js'; +import { orientationVectors } from '../cameraMath.js'; +import { createCameraModel } from './cameraModel.js'; + +const { cartesian2, cartesian3, math, matrix4 } = geo; +const near = (actual, expected, tolerance, label) => + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${label}: ${actual} is not within ${tolerance} of ${expected}`, + ); + +// A stand-in for Navara's camera: setCamera lands on the next update with +// Navara's ±89.99° pitch clamp; flights resolve when the test says so. +function createFakePort() { + const port = { + applied: [], + flights: [], + stops: 0, + frames: 0, + drawn: null, + width: 1280, + height: 800, + apply(pose) { + port.applied.push(pose); + }, + read: () => port.drawn, + fly(pose, options) { + let settle; + const promise = new Promise((resolve) => (settle = resolve)); + port.flights.push({ pose, options, settle }); + return promise; + }, + stop() { + port.stops++; + }, + size: () => ({ width: port.width, height: port.height }), + requestFrame() { + port.frames++; + }, + /** Navara's update: draw a newly applied pose (pitch clamped). */ + drawnCount: 0, + update() { + if (port.applied.length === port.drawnCount) return; + port.drawnCount = port.applied.length; + const pose = port.applied.at(-1); + const pitch = Math.max(-89.99, Math.min(89.99, pose.pitch)); + const position = cartesian3.fromDegrees(pose.lng, pose.lat, pose.height); + port.drawn = { + position, + ...orientationVectors( + cartesian3.fromDegrees(pose.lng, pose.lat, 0), + math.toRadians(pose.heading), + math.toRadians(pitch), + math.toRadians(pose.roll), + ), + }; + }, + }; + return port; +} + +function createClock() { + const clock = { + time: 0, + timers: [], + now: () => clock.time, + setTimeout(fn, ms) { + const timer = { fn, at: clock.time + ms }; + clock.timers.push(timer); + return timer; + }, + clearTimeout(timer) { + clock.timers = clock.timers.filter((entry) => entry !== timer); + }, + advance(ms) { + clock.time += ms; + for (const timer of [...clock.timers]) + if (timer.at <= clock.time) { + clock.clearTimeout(timer); + timer.fn(); + } + }, + }; + return clock; +} + +function setup() { + const port = createFakePort(); + const clock = createClock(); + const model = createCameraModel({ port, clock }); + // One Navara frame: update, preRender sync, postRender events. + const frame = (ms = 16) => { + clock.advance(ms); + port.update(); + model.sync(); + model.afterFrame(); + }; + return { port, clock, model, camera: model.camera, frame }; +} + +// Cesium's Camera._updateCameraChanged: direction angle / (fovy / 2), and +// heading or roll change / π, each against percentageChanged (0.5). +test('turning in place raises changed as Cesium does', () => { + const { camera, frame } = setup(); + const destination = cartesian3.fromDegrees(10, 45, 5000); + camera.setView({ + destination, + orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, + }); + frame(); + frame(); + let changed = 0; + camera.changed.addEventListener(() => changed++); + // Tilt from straight down to 45° on the spot: 45° > half the fovy. + camera.setView({ + destination, + orientation: { heading: 0, pitch: -(Math.PI / 4), roll: 0 }, + }); + frame(); + assert.equal(changed, 1, 'a 45° tilt in place'); + // Heading +170° looking straight down (north-up reset): direction unchanged. + camera.setView({ + destination, + orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, + }); + frame(); + const before = changed; + camera.setView({ + destination, + orientation: { + heading: math.toRadians(170), + pitch: -math.PI_OVER_TWO, + roll: 0, + }, + }); + frame(); + assert.equal(changed, before + 1, 'a 170° heading turn in the nadir'); +}); diff --git a/src/globe/navara/cameraModel.js b/src/globe/navara/cameraModel.js index 6f8fe17b9..e26414f35 100644 --- a/src/globe/navara/cameraModel.js +++ b/src/globe/navara/cameraModel.js @@ -12,7 +12,9 @@ // wall time (Cesium's cameraEventWaitTime), changed past percentageChanged. // // The port is the engine's thin layer over the Navara view (apply, read, fly, -// stop, size, requestFrame); see createNavaraEngine.js. +// stop, size, requestFrame, suspendCameraInputs); see createNavaraEngine.js. +// A flight suspends the camera inputs until it ends, as Cesium's +// CameraFlightPath turns them off: a gesture cannot cut it short. import * as geo from '../../geo/index.js'; import { createEngineEvent } from '../contract.js'; @@ -81,6 +83,7 @@ function posesNear(a, b, distance, angle) { * stop: () => void, * size: () => { width: number, height: number }, * requestFrame: () => void, + * suspendCameraInputs?: () => () => void, * }, * clock?: { now: () => number, setTimeout: Function, clearTimeout: Function }, * }} options @@ -124,6 +127,11 @@ export function createCameraModel({ let lastSeen = initial ? copyPose(initial) : null; let lastFramePose = copyPose(state); let lastChangedPose = copyPose(state); + let changedAngles = headingPitchRoll( + state.position, + state.direction, + state.up, + ); let moving = false; let lastMoveAt = 0; let moveEndTimer = null; @@ -175,6 +183,7 @@ export function createCameraModel({ function endFlight(current, outcome) { if (flight !== current) return; flight = null; + current.resume?.(); if (outcome === 'cancel') current.options.cancel?.(); } @@ -197,7 +206,7 @@ export function createCameraModel({ options.complete?.(); return; } - const current = { options }; + const current = { options, resume: port.suspendCameraInputs?.() }; flight = current; // Navara moves the camera from here on; read it back every frame. pushed = null; @@ -212,7 +221,7 @@ export function createCameraModel({ (arrived) => { if (flight !== current || destroyed) return; if (!arrived) return endFlight(current, 'cancel'); - flight = null; + endFlight(current, 'complete'); land(options, target); options.complete?.(); }, @@ -261,13 +270,28 @@ export function createCameraModel({ } lastFramePose = pose; const turn = - angleBetween(pose.direction, lastChangedPose.direction) / Math.PI; + angleBetween(pose.direction, lastChangedPose.direction) / + (view().fovy / 2); const altitude = Math.max( 1, cartographic.fromCartesian(pose.position, WGS84).height, ); const shift = cartesian3.distance(pose.position, lastChangedPose.position) / altitude; + const angles = headingPitchRoll(pose.position, pose.direction, pose.up); + const delta = (a, b) => { + const angle = Math.abs(a - b) % (2 * Math.PI); + return Math.min(angle, 2 * Math.PI - angle) / Math.PI; + }; + // Heading and roll are raised on their own, as Camera._updateCameraChanged + // does: a turn about the line of sight (heading at nadir, roll) moves + // neither the direction nor the position. + for (const angle of ['heading', 'roll']) { + const amount = delta(angles[angle], changedAngles[angle]); + if (amount <= percentageChanged) continue; + changedAngles[angle] = angles[angle]; + events.changed.raiseEvent(amount); + } const amount = Math.max(turn, shift); if (amount > percentageChanged) { lastChangedPose = pose; @@ -311,6 +335,7 @@ export function createCameraModel({ if (!options?.destination) throw new TypeError('setView needs a destination'); announce(); + cancelCurrentFlight(); place(viewFor(options.destination, options.orientation)); }, flyTo(options) { @@ -395,11 +420,6 @@ export function createCameraModel({ camera, sync, afterFrame, - /** - * Ends a running flight (its `cancel` runs) without camera.commanded: - * for the user's own gestures, which are not a new camera owner. - */ - stopFlight: () => cancelCurrentFlight(), /** * Whether nothing else drives the camera: no flight and no lookAt target * (identity transform). The engine's ground floor acts only then. diff --git a/src/globe/navara/cameraModel.test.mjs b/src/globe/navara/cameraModel.test.mjs index 32b8960eb..6e6980575 100644 --- a/src/globe/navara/cameraModel.test.mjs +++ b/src/globe/navara/cameraModel.test.mjs @@ -525,3 +525,26 @@ test('pose carries the view aspect ratio', () => { const { width, height } = port.size(); assert.equal(camera.pose().aspectRatio, width / height); }); + +test('setView cancels a model flight before its promise settles', async () => { + const { camera, port } = setup(); + const outcome = []; + camera.flyTo({ + destination: cartesian3.fromDegrees(0, 0, 50000), + duration: 2, + cancel: () => outcome.push('cancel'), + complete: () => outcome.push('complete'), + }); + const destination = cartesian3.fromDegrees(0, 0, 200000); + camera.setView({ destination }); + assert.equal(camera.flightActive(), false); + assert.deepEqual(outcome, ['cancel']); + port.flights[0].settle(true); + await Promise.resolve(); + near( + cartesian3.distance(camera.pose().position, destination), + 0, + 1e-6, + 'setView stays', + ); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index a8a3b8adc..f5bd521a0 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -34,6 +34,8 @@ import { createNavaraSurface } from './surface.js'; import { createGroundFloor } from './groundFloor.js'; const { cartesian3, math } = geo; +// Past the far side of the Earth: a ray from any camera to any tile. +const TILE_PICK_FAR_M = 2e7; // Navara's drawn camera (view.camera.raw, a three PerspectiveCamera in // Earth-fixed metres): matrixWorld columns are right, up, back, position. @@ -129,6 +131,7 @@ export function createNavaraEngine( stop: () => view.setCamera({}), size, requestFrame: () => view.forceUpdate(), + suspendCameraInputs: () => input.input.suspendCameraInputs(), }, ...(clock ? { clock } : {}), }); @@ -162,6 +165,15 @@ export function createNavaraEngine( // Between the view's preRender and postRender: a change made then shows // in the frame being drawn and asks for no other. let drawing = false; + // Navara clears its frame request after the frame it draws, so a + // render.request made during one (preRender to the end of postRender) is + // made again once that frame is done: the next frame follows, as on Cesium. + let frameOpen = false; + let requestedInFrame = false; + const requestRender = () => { + view.forceUpdate(); + if (frameOpen) requestedInFrame = true; + }; const handlers = { preUpdate: (time) => { // Before the view decides whether it is idle (internals.js). @@ -171,6 +183,13 @@ export function createNavaraEngine( }, preRender: (time) => { drawing = true; + frameOpen = true; + queueMicrotask(() => { + frameOpen = false; + if (!requestedInFrame || destroyed) return; + requestedInFrame = false; + view.forceUpdate(); + }); model.sync(); // After the user's camera is read, before anything draws. followStep.raiseEvent(); @@ -202,11 +221,7 @@ export function createNavaraEngine( lines: () => linesPort?.pickables?.() ?? [], resolveBatch: (batchId) => moversPort?.resolvePick?.(batchId) ?? null, }); - const input = createNavaraInput(view, { - // A gesture is the user, not a new camera owner: no camera.commanded. - onCameraInput: () => model.stopFlight(), - picker: picking, - }); + const input = createNavaraInput(view, { picker: picking }); const credits = createNavaraCredits(view); const maps = createNavaraMaps(view, fetch ? { fetch } : {}); const imagery = @@ -216,7 +231,9 @@ export function createNavaraEngine( credits: credits.credits, photorealActive: () => maps.maps.photorealActive(), }) ?? null; - const { surface } = createNavaraSurface(view, hooks); + const { surface } = createNavaraSurface(view, hooks, (positions) => + maps.sampleTerrain(positions), + ); // Navara's controller zooms through the ground; keep the camera above it. // With the camera inputs off or suspended nothing zooms, and the app places // the camera itself (the cockpit eye, 12 m up): the floor's 30 m would @@ -233,14 +250,28 @@ export function createNavaraEngine( ...(clock ? { now: () => clock.now() } : {}), }); - // Ground items are picked on the CPU at the globe under the pointer - // (src/globe/ground.js), under every mover and line. + // Ground items are picked on the CPU at the visible surface under the pointer + // (src/globe/ground.js), under every mover and line: the 3D tiles while + // they show, else the globe. A CPU ray, not the depth read, which answers + // with the near plane until a frame after Navara's pick of a click. + const tilesUnder = (screen) => { + if (!maps.maps.photorealActive()) return undefined; + const ray = model.camera.pickRay(screen); + return ray + ? surface.raycast({ + origin: ray.origin, + direction: ray.direction, + far: TILE_PICK_FAR_M, + })?.position + : undefined; + }; const removeGroundPicks = picking.registerMeshPicker( createGroundPicks({ - pickGlobe: (screen) => picker.pickGlobe(screen), + pickGlobe: (screen) => tilesUnder(screen) ?? picker.pickGlobe(screen), camera: model.camera, size, sets: () => groundPort?.pickables?.() ?? [], + surface: () => (maps.maps.photorealActive() ? 'tiles' : 'terrain'), }), ); const picker = { @@ -326,6 +357,33 @@ export function createNavaraEngine( }, } : {}; + let maxTextureSize = null; + // Movers on screen now: shown, not transparent, their anchor in the view + // and over the horizon. + const clip = geo.matrix4.create(); + const at = geo.cartesian3.create(); + const visibleContacts = () => { + model.viewProjection(clip); + let shown = 0; + for (const { store } of moversPort?.pickables?.() ?? []) { + const { positions, shows, colors, count } = store; + for (let index = 0; index < count; index++) { + if (shows[index] !== 1 || colors[index * 4 + 3] <= 0) continue; + at.x = positions[index * 3]; + at.y = positions[index * 3 + 1]; + at.z = positions[index * 3 + 2]; + const w = clip[3] * at.x + clip[7] * at.y + clip[11] * at.z + clip[15]; + if (w <= 0) continue; + const x = + (clip[0] * at.x + clip[4] * at.y + clip[8] * at.z + clip[12]) / w; + const y = + (clip[1] * at.x + clip[5] * at.y + clip[9] * at.z + clip[13]) / w; + if (Math.abs(x) > 1 || Math.abs(y) > 1) continue; + if (model.horizonVisible(at)) shown++; + } + } + return shown; + }; let bloom = { ...BLOOM_INITIAL }; const bloomRender = bloomPort ? { @@ -385,11 +443,19 @@ export function createNavaraEngine( }, frames, render: { - request: () => view.forceUpdate(), + request: requestRender, setContinuous(enabled) { view.animation = Boolean(enabled); }, isContinuous: () => view.animation === true, + // The canvas's own context (getContext hands back the one the view + // made); Navara has no public renderer to ask. + maxTextureSize: () => + (maxTextureSize ??= (() => { + const gl = + canvas.getContext?.('webgl2') ?? canvas.getContext?.('webgl'); + return gl?.getParameter(gl.MAX_TEXTURE_SIZE) ?? 0; + })()), setSharpen(options) { const next = mergeSharpen(sharpen, options); const changed = @@ -415,6 +481,13 @@ export function createNavaraEngine( ground: groundPort.ground, meshes: meshes.meshes, imagery: imagery.imagery, + // Not in the contract, as Cesium's (src/cyberSonarEngine.js reads it): + // the treatment runs in the movers' shaders; `contacts` is how many are + // on screen, so an empty view lets the sonar go idle. + contactSonarDiagnostics: () => ({ + supported: true, + contacts: visibleContacts(), + }), destroy() { if (destroyed) return; destroyed = true; diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index cfa481c2c..7faea5415 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -446,7 +446,79 @@ test("Navara's fov follows Cesium's vertical fov, also after a resize", () => { near(engine.camera.pose().fovy, Math.PI / 3, 1e-15, 'pose fovy'); }); -test('cancelFlight stops Navara with an empty setCamera; user input cancels a flight', () => { +test('contactSonarDiagnostics counts the movers on screen', () => { + const view = fakeView(); + // The camera is 10 000 km over (0°, 0°), looking down. + const places = [ + [0, 0, 1], + [1, 1, 1], + [0, 0, 0], + [180, 0, 1], + ]; + const positions = new Float64Array(places.length * 3); + const colors = new Float32Array(places.length * 4).fill(1); + const shows = new Uint8Array(places.length).fill(1); + places.forEach(([lon, lat, show], index) => { + const position = cartesian3.fromDegrees(lon, lat, 0); + positions.set([position.x, position.y, position.z], index * 3); + shows[index] = show; + }); + colors[1 * 4 + 3] = 0; + const store = { positions, colors, shows, count: places.length }; + const engine = createNavaraEngine(view, { + ...ports, + movers: { ...ports.movers, pickables: () => [{ store }] }, + hooks, + }); + // Shown and opaque, in view: the first; the second is transparent, the + // third hidden, the fourth behind the Earth. + assert.deepEqual(engine.contactSonarDiagnostics(), { + supported: true, + contacts: 1, + }); + engine.destroy(); +}); + +test("maxTextureSize reads the canvas's WebGL limit once", () => { + const view = fakeView(); + const asked = []; + view.canvas.getContext = (type) => { + asked.push(type); + return type === 'webgl2' + ? { MAX_TEXTURE_SIZE: 0x0d33, getParameter: () => 8192 } + : null; + }; + const engine = createNavaraEngine(view, { ...ports, hooks }); + assert.equal(engine.render.maxTextureSize(), 8192); + assert.equal(engine.render.maxTextureSize(), 8192); + assert.deepEqual(asked, ['webgl2']); + engine.destroy(); +}); + +test('a render requested while a frame draws asks for the next frame', async () => { + const { engine, view } = setup(); + const requests = () => + view.calls.filter(([name]) => name === 'forceUpdate').length; + // The wind's flow asks for its next frame from preRender. + const off = engine.frames.preRender.addEventListener(() => + engine.render.request('wind'), + ); + view.emit('preRender', 1); + view.emit('postRender', 1); + // Navara clears its request flag once the frame is drawn. + const drawn = requests(); + await Promise.resolve(); + assert.equal(requests(), drawn + 1, 'requested again after the frame'); + off(); + view.emit('preRender', 2); + view.emit('postRender', 2); + const idle = requests(); + await Promise.resolve(); + assert.equal(requests(), idle, 'no request, no next frame'); + engine.destroy(); +}); + +test('cancelFlight stops Navara with an empty setCamera; user input leaves a flight running', () => { const { engine, view } = setup(); const outcome = []; engine.camera.flyTo({ @@ -463,8 +535,9 @@ test('cancelFlight stops Navara with an empty setCamera; user input cancels a fl cancel: () => outcome.push('input'), }); view.canvas.dispatchEvent(new Event('wheel')); - assert.deepEqual(outcome, ['cancel', 'input']); - assert.equal(engine.camera.flightActive(), false); + assert.deepEqual(outcome, ['cancel']); + assert.equal(engine.camera.flightActive(), true); + engine.destroy(); }); test('pickPosition reads Navara depth at client coordinates; pick finds no tiles', () => { @@ -536,7 +609,7 @@ test('pickGlobe meets the terrain along the pick ray, or the ellipsoid', () => { ); }); -test('a user gesture cancels a flight without raising camera.commanded', () => { +test('a user gesture leaves a flight running without raising camera.commanded', () => { const { engine, view } = setup(); const outcome = []; let commanded = 0; @@ -548,8 +621,8 @@ test('a user gesture cancels a flight without raising camera.commanded', () => { engine.camera.commanded.addEventListener(() => commanded++); view.canvas.dispatchEvent(new Event('pointerdown')); view.canvas.dispatchEvent(new Event('wheel')); - assert.deepEqual(outcome, ['cancel']); - assert.equal(engine.camera.flightActive(), false); + assert.deepEqual(outcome, []); + assert.equal(engine.camera.flightActive(), true); assert.equal(commanded, 0, 'a gesture is the user, not a new camera owner'); }); @@ -630,17 +703,39 @@ test('the floor stands aside while the camera inputs are off (the cockpit drives engine.destroy(); }); -test('the floor stands aside while the camera inputs are suspended (the cockpit holds them)', () => { - const { engine, view } = setup(); - view.sampleTerrainHeight = () => 100; - const resume = engine.input.suspendCameraInputs(); - engine.camera.setView({ - destination: cartesian3.fromDegrees(2.35, 48.85, 112), +test('ground picks use the visible surface and fall back to the globe', async () => { + const { normalizeGroundItem } = await import('../ground.js'); + const view = fakeView(); + // Right after a click Navara's depth read answers with its near plane. + view.pickDepthPosition = () => cartesian3.fromDegrees(10.5, 10, 45900); + const options = { id: 'rooftop', kind: 'disc', pickable: true, order: 0 }; + const item = normalizeGroundItem( + 'disc', + { center: [10, 10], radiusM: 100, fill: [1, 0, 0, 1], pickId: 'roof' }, + 0, + ); + const engine = createNavaraEngine(view, { + ...ports, + hooks, + ground: { + ...ports.ground, + pickables: () => [{ options, items: [item], shown: true }], + }, }); - view.emit('preRender', 1); - near(heightOf(engine), 112, 1e-6, 'the cockpit eye 12 m up is kept'); - resume(); - view.emit('preRender', 2); - near(heightOf(engine), 130, 1e-6, 'lifted once the inputs are back'); + const at = { x: 640, y: 400 }; + let globe = cartesian3.fromDegrees(10, 10, 0); + engine.picker.pickGlobe = () => globe; + let tiles = null; + engine.surface.raycast = () => (tiles ? { position: tiles } : undefined); + assert.equal(engine.picker.pick(at)?.id, 'roof', 'the globe, not the depth'); + // Under 3D tiles a tilted view meets the roof before the terrain. + engine.maps.photorealActive = () => true; + globe = cartesian3.fromDegrees(10.01, 10, 0); + tiles = cartesian3.fromDegrees(10, 10, 40); + assert.equal(engine.picker.pick(at)?.id, 'roof', 'the tiles shown'); + tiles = null; + assert.equal(engine.picker.pick(at), undefined, 'a tile miss: the globe'); + globe = cartesian3.fromDegrees(10, 10, 0); + assert.equal(engine.picker.pick(at)?.id, 'roof'); engine.destroy(); }); diff --git a/src/globe/navara/flightInput.test.mjs b/src/globe/navara/flightInput.test.mjs new file mode 100644 index 000000000..f6ba48b5e --- /dev/null +++ b/src/globe/navara/flightInput.test.mjs @@ -0,0 +1,186 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../geo/index.js'; +import { assertGlobeEngine, engineCapabilities } from '../contract.js'; +import { cesiumFovy, orientationVectors } from '../cameraMath.js'; +import { createNavaraEngine } from './createNavaraEngine.js'; + +const { cartesian2, cartesian3, math } = geo; + +// The parts of a Navara ThreeView the engine uses. +function fakeView() { + const handlers = new Map(); + const canvas = new EventTarget(); + Object.assign(canvas, { + clientWidth: 1280, + clientHeight: 800, + getBoundingClientRect: () => ({ left: 10, top: 20 }), + parentElement: { id: 'container' }, + }); + const position = cartesian3.fromDegrees(0, 0, 1e7); + const { direction, up } = orientationVectors( + position, + 0, + -math.PI_OVER_TWO, + 0, + ); + // three's camera.matrixWorld: columns right, up, back (−direction), position. + const right = cartesian3.cross(direction, up, cartesian3.create()); + const elements = [ + right.x, + right.y, + right.z, + 0, + up.x, + up.y, + up.z, + 0, + -direction.x, + -direction.y, + -direction.z, + 0, + position.x, + position.y, + position.z, + 1, + ]; + const view = { + canvas, + animation: false, + calls: [], + flights: [], + on(name, fn) { + if (!handlers.has(name)) handlers.set(name, new Set()); + handlers.get(name).add(fn); + }, + off(name, fn) { + handlers.get(name)?.delete(fn); + }, + emit(name, ...args) { + for (const fn of [...(handlers.get(name) ?? [])]) fn(...args); + }, + listenerCount: () => + [...handlers.values()].reduce((count, set) => count + set.size, 0), + camera: { + raw: { matrixWorld: { elements }, updateMatrixWorld() {} }, + fovs: [], + set fov(value) { + view.camera.fovs.push(value); + }, + set options(value) { + view.calls.push(['options', value]); + }, + }, + setCamera(pose) { + view.calls.push(['setCamera', pose]); + }, + flyTo(pose, options) { + return new Promise((resolve) => + view.flights.push({ pose, options, resolve }), + ); + }, + forceUpdate() { + view.calls.push(['forceUpdate']); + }, + pickDepthPosition(x, y) { + view.calls.push(['pickDepthPosition', x, y]); + return x > 100 ? { x: 1, y: 2, z: 3, isVector3: true } : null; + }, + sampleTerrainHeight: () => undefined, + addSource: (desc) => ({ id: 's', desc, delete: () => true }), + addLayer: (desc) => ({ id: 'l', desc, delete() {} }), + attribution: { add() {}, remove() {} }, + }; + return view; +} + +const hooks = { + tileRoots: () => [], + isIdle: () => true, + retainTileGeometry() {}, +}; + +// Primitive ports for tests of engine orchestration. +const STUB_MOVERS = Object.freeze({ + createSet() { + throw new Error("Navara movers need the view's three runtime (movers/)"); + }, + list: () => [], +}); +const STUB_LINES = Object.freeze({ + createSegments() { + throw new Error("Navara lines need the view's three runtime (lines/)"); + }, + createLine() { + throw new Error("Navara lines need the view's three runtime (lines/)"); + }, + list: () => [], +}); +const STUB_MESHES = Object.freeze({ + createMesh() { + throw new Error('Navara meshes need the view runtime'); + }, + list: () => [], +}); +const STUB_GROUND = Object.freeze({ + createPulseField() { + throw new Error('Navara ground needs the view runtime'); + }, + createSet() { + throw new Error("Navara ground needs the view's three runtime (ground/)"); + }, + list: () => [], +}); +const STUB_IMAGERY = Object.freeze({ + createLayer() { + throw new Error("Navara imagery needs the view's three runtime (imagery/)"); + }, + list: () => [], + setSplitPosition() {}, + splitPosition: () => 0.5, + surface: () => 'globe', +}); + +const ports = { + movers: { movers: STUB_MOVERS, destroy() {} }, + lines: { lines: STUB_LINES, destroy() {} }, + ground: { ground: STUB_GROUND, destroy() {} }, + imagery: () => ({ imagery: STUB_IMAGERY, destroy() {} }), + meshes: () => ({ meshes: STUB_MESHES, destroy() {} }), +}; + +const setup = () => { + const view = fakeView(); + const engine = createNavaraEngine(view, { ...ports, hooks }); + return { view, engine }; +}; + +// Cesium's CameraFlightPath turns the camera inputs off for the flight, so a +// gesture never cancels it; layers/infrastructure suspends the inputs for the +// same reason ("Camera input would cancel the flight"). +test('a wheel during a flight with the camera inputs suspended does not cancel it', async () => { + const { engine, view } = setup(); + const outcome = []; + const restore = engine.input.suspendCameraInputs(); + engine.camera.flyTo({ + destination: cartesian3.fromDegrees(100, 0, 5e6), + duration: 1.5, + complete: () => outcome.push('complete'), + cancel: () => outcome.push('cancel'), + }); + view.canvas.dispatchEvent(new Event('wheel')); + view.canvas.dispatchEvent(new Event('pointerdown')); + assert.deepEqual(outcome, [], 'the flight goes on'); + assert.equal(engine.camera.flightActive(), true); + restore(); + assert.equal( + engine.input.cameraInputsEnabled(), + false, + 'the flight keeps its own suspension', + ); + view.flights[0].resolve(true); + await Promise.resolve(); + assert.deepEqual(outcome, ['complete']); + assert.equal(engine.input.cameraInputsEnabled(), true); + engine.destroy(); +}); diff --git a/src/globe/navara/followGestures.js b/src/globe/navara/followGestures.js index c4becaa9d..3a2438a75 100644 --- a/src/globe/navara/followGestures.js +++ b/src/globe/navara/followGestures.js @@ -9,7 +9,9 @@ // window ratio uses Cesium's maximumMovementRatio cap; a drag // right turns the heading clockwise; // - right drag: a dolly by dy / height (down closes in); -// - wheel: one notch (deltaY 100) is 20 % of the range. +// - wheel: one notch (deltaY 100) is 20 % of the range; +// - two-finger pinch: the range scales by the fingers' distance ratio +// (pointers are told apart by pointerId). const WHEEL_STEP = 1.2; const ROTATE_RATE = 1.77; @@ -29,16 +31,32 @@ export function navaraFollowGestures( ) { return ({ orbit, zoom, dolly }) => { const resume = suspendCameraInputs(); - let last = null; + const pointers = new Map(); + const distance = () => { + const [a, b] = pointers.values(); + return Math.hypot(a.x - b.x, a.y - b.y); + }; const down = (event) => { if (event.button !== 0 && event.button !== 2) return; - last = { x: event.clientX, y: event.clientY, button: event.button }; + pointers.set(event.pointerId, { + x: event.clientX, + y: event.clientY, + button: event.button, + }); }; const move = (event) => { + const last = pointers.get(event.pointerId); if (!last) return; + const before = pointers.size === 2 ? distance() : 0; const dx = event.clientX - last.x; const dy = event.clientY - last.y; - last = { ...last, x: event.clientX, y: event.clientY }; + last.x = event.clientX; + last.y = event.clientY; + if (pointers.size > 1) { + const after = pointers.size === 2 ? distance() : 0; + if (before > 0 && after > 0) zoom(before / after); + return; + } const width = canvas.clientWidth || 1; const height = canvas.clientHeight || 1; if (last.button === 2) { @@ -50,8 +68,8 @@ export function navaraFollowGestures( ROTATE_RATE * Math.min(-dy / height, MAX_MOVEMENT_RATIO) * Math.PI, ); }; - const up = () => { - last = null; + const up = (event) => { + pointers.delete(event.pointerId); }; const wheel = (event) => { if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return; @@ -63,6 +81,7 @@ export function navaraFollowGestures( canvas.addEventListener('pointerdown', down); windowTarget.addEventListener('pointermove', move); windowTarget.addEventListener('pointerup', up); + windowTarget.addEventListener('pointercancel', up); canvas.addEventListener('wheel', wheel, { passive: false }); canvas.addEventListener('contextmenu', contextMenu); return () => { @@ -70,6 +89,8 @@ export function navaraFollowGestures( canvas.removeEventListener('pointerdown', down); windowTarget.removeEventListener('pointermove', move); windowTarget.removeEventListener('pointerup', up); + windowTarget.removeEventListener('pointercancel', up); + pointers.clear(); canvas.removeEventListener('wheel', wheel, { passive: false }); resume(); }; diff --git a/src/globe/navara/followPinch.test.mjs b/src/globe/navara/followPinch.test.mjs new file mode 100644 index 000000000..552361768 --- /dev/null +++ b/src/globe/navara/followPinch.test.mjs @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { navaraFollowGestures } from './followGestures.js'; + +const event = (type, fields = {}) => + Object.assign(new Event(type, { cancelable: true }), fields); + +test('a two-finger pinch while following zooms and does not jerk the heading', () => { + const canvas = new EventTarget(); + canvas.clientWidth = 400; + canvas.clientHeight = 800; + const win = new EventTarget(); + const gestures = navaraFollowGestures(canvas, () => () => {}, win); + const calls = []; + const release = gestures({ + orbit: (h, p) => calls.push(['orbit', h, p]), + zoom: (f) => calls.push(['zoom', f]), + dolly: (r) => calls.push(['dolly', r]), + }); + const touch = (type, pointerId, x, y, target = win) => + target.dispatchEvent( + event(type, { + pointerId, + pointerType: 'touch', + isPrimary: pointerId === 1, + button: type === 'pointermove' ? -1 : 0, + clientX: x, + clientY: y, + }), + ); + // Two fingers 200 px apart spread out symmetrically (a pinch-out). + touch('pointerdown', 1, 100, 400, canvas); + touch('pointerdown', 2, 300, 400, canvas); + for (let i = 1; i <= 4; i++) { + touch('pointermove', 1, 100 - 5 * i, 400); + touch('pointermove', 2, 300 + 5 * i, 400); + } + const turn = calls + .filter(([kind]) => kind === 'orbit') + .reduce((most, [, h]) => Math.max(most, Math.abs(h)), 0); + assert.ok( + turn < 0.2, + `a pinch turned the heading by up to ${turn.toFixed(2)} rad in one move`, + ); + assert.ok( + calls.some(([kind]) => kind === 'zoom' || kind === 'dolly'), + 'a pinch changes the range', + ); + touch('pointercancel', 2, 320, 400); + const count = calls.length; + touch('pointermove', 2, 350, 400); + assert.equal( + calls.length, + count, + 'a cancelled finger no longer moves the camera', + ); + touch('pointermove', 1, 75, 400); + assert.equal( + calls.at(-1)[0], + 'orbit', + 'the remaining finger resumes dragging', + ); + touch('pointerup', 1, 75, 400); + const ended = calls.length; + touch('pointermove', 1, 90, 400); + assert.equal(calls.length, ended); + release(); +}); diff --git a/src/globe/navara/ground/index.js b/src/globe/navara/ground/index.js index 42a9392d3..989bbf304 100644 --- a/src/globe/navara/ground/index.js +++ b/src/globe/navara/ground/index.js @@ -61,6 +61,7 @@ export function createNavaraGround( }; const pack = (entry) => { entry.pack = packSet(entry.options.kind, entry.items, { terrainHeight }); + entry.pack.drape = entry.options.drape; for ( let at = REVEAL_OFFSET; at < entry.pack.items.length; diff --git a/src/globe/navara/ground/index.test.mjs b/src/globe/navara/ground/index.test.mjs index c45c7cfbb..8ae696d52 100644 --- a/src/globe/navara/ground/index.test.mjs +++ b/src/globe/navara/ground/index.test.mjs @@ -274,3 +274,22 @@ test('destroying a revealed corridor leaves the next ground set with fresh pass } port.destroy(); }); + +test('ground sets carry their surface classification into the frame texture', async () => { + const { matrix4 } = await import('../../../geo/index.js'); + const pass = fakePass(); + const port = createNavaraGround(fakeView(), { createPass: () => pass }); + for (const [drape, expected] of [ + ['terrain', 1], + ['tiles', 2], + ['both', 0], + ]) { + const set = port.ground.createSet({ id: drape, kind: 'polygon', drape }); + set.replace([square(0)]); + set.commit(); + const state = pass.owner.state; + state.update(matrix4.IDENTITY, matrix4.IDENTITY, 800, 600); + assert.equal(state.frames.data[(state.count - 1) * 16 + 15], expected); + } + port.destroy(); +}); diff --git a/src/globe/navara/ground/pack.js b/src/globe/navara/ground/pack.js index e64c1f780..5c47fa20b 100644 --- a/src/globe/navara/ground/pack.js +++ b/src/globe/navara/ground/pack.js @@ -524,6 +524,7 @@ export function composeGround(packs) { 0, ); const frames64 = new Float64Array(count * 13); + const drapes = new Uint8Array(count); const volumes = new Float64Array(count * 4); const heights = new Float64Array(count * 2); const styles = texelStorage(count * STYLE_TEXELS); @@ -543,6 +544,11 @@ export function composeGround(packs) { let run = 0; let look = 0; for (const pack of packs) { + drapes.fill( + pack.drape === 'terrain' ? 1 : pack.drape === 'tiles' ? 2 : 0, + item, + item + pack.count, + ); frames64.set(pack.frames, item * 13); volumes.set(pack.volumes, item * 4); heights.set(pack.heights, item * 2); @@ -585,6 +591,7 @@ export function composeGround(packs) { const box = new Float64Array(4); const lastView = new Float64Array(16).fill(Number.NaN); const lastProjection = new Float64Array(16).fill(Number.NaN); + let lastPixelRatio = 1; let lastWidth = 0; let lastHeight = 0; const clampTo = (value, max) => Math.min(max - 1, Math.max(0, value)); @@ -602,8 +609,11 @@ export function composeGround(packs) { return candidates; }, /** True when the frames, grid or candidates changed (upload them). */ - update(view, projection, width, height) { - let changed = width !== lastWidth || height !== lastHeight; + update(view, projection, width, height, pixelRatio = 1) { + let changed = + width !== lastWidth || + height !== lastHeight || + pixelRatio !== lastPixelRatio; for (let index = 0; index < 16 && !changed; index++) changed = view[index] !== lastView[index] || @@ -613,6 +623,7 @@ export function composeGround(packs) { lastProjection.set(projection); lastWidth = width; lastHeight = height; + lastPixelRatio = pixelRatio; const m = view; counts.fill(0); for (let index = 0; index < count; index++) { @@ -636,11 +647,12 @@ export function composeGround(packs) { frames.data.set(viewFrame.subarray(3, 6), t + 4); frames.data.set(viewFrame.subarray(6, 9), t + 8); frames.data.set(viewFrame.subarray(9, 12), t + 12); + frames.data[t + 15] = drapes[index]; const v = index * 4; // The item's extent in its plane and its padding in pixels, for // the shader's early rejection. frames.data[t + 7] = volumes[v]; - frames.data[t + 11] = volumes[v + 3]; + frames.data[t + 11] = volumes[v + 3] * pixelRatio; const distance = Math.hypot(viewFrame[0], viewFrame[1], viewFrame[2]); // Heights relative to the plane's origin on the ellipsoid; the // plane dips under the curve by r²/2R at the item's edge. @@ -665,8 +677,8 @@ export function composeGround(packs) { ranges[r] = -1; continue; } - const padX = (2 * volumes[v + 3]) / Math.max(1, width); - const padY = (2 * volumes[v + 3]) / Math.max(1, height); + const padX = (2 * volumes[v + 3] * pixelRatio) / Math.max(1, width); + const padY = (2 * volumes[v + 3] * pixelRatio) / Math.max(1, height); ranges[r] = clampTo( Math.floor(((box[0] - padX) * 0.5 + 0.5) * GRID_COLS), GRID_COLS, diff --git a/src/globe/navara/ground/pack.test.mjs b/src/globe/navara/ground/pack.test.mjs index 253e07c88..38707d186 100644 --- a/src/globe/navara/ground/pack.test.mjs +++ b/src/globe/navara/ground/pack.test.mjs @@ -502,3 +502,22 @@ test('radial discs pack both transitions, alpha and emission without segment dat ); assert.equal(pack.segments.length, 0); }); + +test('screen padding follows the render pixel ratio', () => { + const pack = packSet( + 'disc', + items('disc', [ + { + center: [0, 0], + radiusM: 100, + outline: { color: [1, 1, 1, 1], widthPx: 8 }, + }, + ]), + ); + const state = composeGround([pack]); + const identity = geo.matrix4.IDENTITY; + state.update(identity, identity, 800, 600, 1); + const padding = state.frames.data[11]; + assert.equal(state.update(identity, identity, 800, 600, 2), true); + assert.equal(state.frames.data[11], padding * 2); +}); diff --git a/src/globe/navara/ground/pass.js b/src/globe/navara/ground/pass.js index 3927e8ab6..21545e6d9 100644 --- a/src/globe/navara/ground/pass.js +++ b/src/globe/navara/ground/pass.js @@ -44,6 +44,7 @@ const float = (value) => (Number.isInteger(value) ? `${value}.0` : `${value}`); const fragmentShader = /* glsl */ ` uniform sampler2D tAllDepth; uniform sampler2D tSurfaceDepth; +uniform sampler2D tGlobeDepth; uniform highp sampler2D tFrames; uniform highp sampler2D tStyles; uniform highp sampler2D tSegments; @@ -73,7 +74,7 @@ vec3 blendCorridor(vec3 color, int item, float nearest, vec2 normal, float along // Metres a pixel spans across the line (the screen gradient of the // distance to it): its width in pixels the same at any angle, as Cesium's. float pixel = max(length(vec2(dot(normal, localDx), dot(normal, localDy))), 1e-4); - float halfWidth = shape.x > 0.0 ? shape.x * 0.5 * pixel : shape.y * 0.5; + float halfWidth = shape.x > 0.0 ? shape.x * uPixelRatio * 0.5 * pixel : shape.y * 0.5; float cover = clamp((halfWidth - nearest) / pixel + 0.5, 0.0, 1.0); if (cover <= 0.0) return color; vec4 fill = atSlot(tItems, at); @@ -137,6 +138,8 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) float surface = texture2D(tSurfaceDepth, uv).r; // The packed depth also holds forward meshes, which are not ground. if (depth < surface - 2e-7) return; + float globeDepth = unpackRGBAToDepth(texture2D(tGlobeDepth, uv)); + bool onTiles = surface < globeDepth - 2e-7; vec3 color = toDisplay(inputColor.rgb); // The pixel's ground in world space, once: every corridor tile tests the // same value, so exactly one tile of a set holds it. @@ -150,7 +153,9 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) vec4 origin = atSlot(tFrames, id * ${TEXELS}); vec4 east = atSlot(tFrames, id * ${TEXELS} + 1); vec4 north = atSlot(tFrames, id * ${TEXELS} + 2); - vec3 up = atSlot(tFrames, id * ${TEXELS} + 3).xyz; + vec4 surfaceFrame = atSlot(tFrames, id * ${TEXELS} + 3); + if ((surfaceFrame.w == 1.0 && onTiles) || (surfaceFrame.w == 2.0 && !onTiles)) continue; + vec3 up = surfaceFrame.xyz; vec4 span = atSlot(tStyles, id * ${STYLE_TEXELS} + 3); vec3 delta = point - origin.xyz; float height = dot(delta, up); @@ -190,7 +195,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) // A pixel past the bins (beyond an open side) takes the bin nearest // it, with a margin that reaches it too. float past = length(max(vec2(0.0), max(place.xy - local, local - open.zw))); - float need = (lineColor.z * pixelMax + past) / (${float(MARGIN_RATIO)} * shape.y); + float need = (lineColor.z * uPixelRatio * pixelMax + past) / (${float(MARGIN_RATIO)} * shape.y); int level = need <= 1.0 ? 0 : min(int(lineColor.w + 0.5), int(ceil(log2(need)))); int columns = int(shape.z + 0.5); int rows = int(shape.w + 0.5); @@ -264,7 +269,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) vec2 centre = vec2(0.0); vec4 grid = atSlot(tStyles, id * ${STYLE_TEXELS} + 4); if (grid.x > 0.5 && - (length(localDx) + length(localDy)) * (shape.y * 0.5 + 1.5) <= grid.z * 0.5) { + (length(localDx) + length(localDy)) * (shape.y * uPixelRatio * 0.5 + 1.5) <= grid.z * 0.5) { vec2 origin = atSlot(tStyles, id * ${STYLE_TEXELS} + 5).xy; ivec2 cell = ivec2(floor((local - origin) / grid.z)); // Off the grid: outside the area, beyond any edge's reach. @@ -317,7 +322,7 @@ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) if (fillColor.a > 0.0) color = mix(color, fillColor.rgb, fillColor.a * clamp(inward / pixel + 0.5, 0.0, 1.0)); if (lineColor.a > 0.0 && shape.y > 0.0) - color = mix(color, lineColor.rgb, lineColor.a * clamp(shape.y * 0.5 - nearest / pixel + 0.5, 0.0, 1.0)); + color = mix(color, lineColor.rgb, lineColor.a * clamp(shape.y * uPixelRatio * 0.5 - nearest / pixel + 0.5, 0.0, 1.0)); } outputColor = vec4(toLinear(color), inputColor.a); } @@ -401,6 +406,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) { attributes: EffectAttribute.CONVOLUTION, uniforms: new Map([ ...groundSurfaceUniforms(), + ['tGlobeDepth', new Uniform(null)], ['tFrames', new Uniform(null)], ['tStyles', new Uniform(null)], ['tSegments', new Uniform(null)], @@ -425,12 +431,15 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) { updateGroundSurface(this.effect, camera, this.ctx, mrt); const input = this.ctx.getInputBuffer(); const { state } = owner; + const cssWidth = this.view.canvas?.clientWidth; + const pixelRatio = cssWidth > 0 ? input.width / cssWidth : 1; if ( state.update( camera.matrixWorldInverse.elements, camera.projectionMatrix.elements, input.width, input.height, + pixelRatio, ) ) { const textures = owner.textures; @@ -444,10 +453,10 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) { textures.candidates.needsUpdate = true; } const u = this.effect.uniforms; + u.get('tGlobeDepth').value = mrt.globeDepthBuffer; u.get('uViewToWorld').value.copy(camera.matrixWorld); u.get('uTime').value = groundSeconds(); - const cssWidth = this.view.canvas?.clientWidth; - u.get('uPixelRatio').value = cssWidth > 0 ? input.width / cssWidth : 1; + u.get('uPixelRatio').value = pixelRatio; u.get('tFrames').value = owner.textures.frames; u.get('tStyles').value = owner.textures.styles; u.get('tSegments').value = owner.textures.segments; diff --git a/src/globe/navara/imagery/index.js b/src/globe/navara/imagery/index.js index 26964b14c..e69031371 100644 --- a/src/globe/navara/imagery/index.js +++ b/src/globe/navara/imagery/index.js @@ -345,14 +345,13 @@ export function createNavaraImagery( current.canvas.width = current.canvas.height = 0; queue.prune(); }; - // The source's rectangle within the tiling's latitudes; across the - // antimeridian the atlas covers every longitude. + // The source's rectangle within the tiling's latitudes. const limit = scheme === 'geographic' ? 90 : WEB_MERCATOR_LIMIT; const box = () => { const { west, south, east, north } = source.rectangle; return { - west: west < east ? west : -180, - east: west < east ? east : 180, + west, + east, south: Math.max(south, -limit), north: Math.min(north, limit), }; @@ -416,7 +415,8 @@ export function createNavaraImagery( tasks.push({ url: imageryTileUrl( source.url, - plan.x + x, + (plan.x + x) % + 2 ** (plan.z + (scheme === 'geographic' ? 1 : 0)), plan.y + y, plan.z, source.subdomains, @@ -625,7 +625,7 @@ export function createNavaraImagery( }, ); - const offCamera = camera.changed.addEventListener(() => { + const viewChanged = () => { let changed = false; for (const entry of entries.values()) { changed = entry.drawing.cameraChanged() || changed; @@ -633,12 +633,15 @@ export function createNavaraImagery( } // Raised layers rise with the camera. if (changed || liftNow() !== lift) compose(); - }); + }; + const offCamera = camera.changed.addEventListener(viewChanged); + view.on?.('resize', viewChanged); return { imagery: registry.imagery, destroy() { offCamera(); + view.off?.('resize', viewChanged); registry.destroyAll(); pass.destroy(); }, diff --git a/src/globe/navara/imagery/index.test.mjs b/src/globe/navara/imagery/index.test.mjs index 7e167d4a9..69a68e6a0 100644 --- a/src/globe/navara/imagery/index.test.mjs +++ b/src/globe/navara/imagery/index.test.mjs @@ -6,7 +6,7 @@ import { MAX_IMAGERY_SLOTS, createNavaraImagery } from './index.js'; const BOX = { west: -97.77, south: 30.25, east: -97.71, north: 30.29 }; const tick = () => new Promise((resolve) => setImmediate(resolve)); -function fixture() { +function fixture(rectangle = null) { const canvases = []; const draws = []; const jobs = []; @@ -15,7 +15,11 @@ function fixture() { const changed = createEngineEvent(); let photoreal = false; let frames = 0; + let height = 800; + const events = new Map(); const view = { + on: (name, run) => events.set(name, run), + off: (name) => events.delete(name), forceUpdate: () => frames++, }; const { imagery, destroy } = createNavaraImagery(view, { @@ -25,9 +29,9 @@ function fixture() { cartographic: { height: 12_000, latitude: 0.53 }, fovy: 0.7, }), - viewRectangle: () => null, + viewRectangle: () => rectangle, }, - size: () => ({ width: 1280, height: 800 }), + size: () => ({ width: 1280, height }), credits: { addStatic(html) { credits.set(html, (credits.get(html) ?? 0) + 1); @@ -52,6 +56,10 @@ function fixture() { }, }); return { + resize: (next) => { + height = next; + events.get('resize')?.(); + }, imagery, destroy, canvases, @@ -300,3 +308,43 @@ test('a raised image is lifted with the camera; its detail is a slot over it', a assert.equal(f.last().slots.length, 1); assert.equal(f.last().slots[0].look[3], 0); }); + +test('resizing replans a tile atlas without a camera change', async () => { + const f = fixture(); + f.imagery.createLayer({ id: 'resize', source: tiles({ maxLevel: 20 }) }); + await tick(); + const before = f.canvases.length; + f.resize(1600); + await tick(); + assert.ok(f.canvases.length > before); + f.destroy(); +}); + +test('an atlas crossing the antimeridian requests valid columns on both sides', async () => { + const r = Math.PI / 180; + const f = fixture({ + west: 179.5 * r, + east: -179.5 * r, + south: -17 * r, + north: -16 * r, + }); + f.imagery.createLayer({ + id: 'seam', + source: tiles({ + minLevel: 10, + maxLevel: 10, + rectangle: { west: -180, east: 180, south: -85, north: 85 }, + }), + }); + let loaded = 0; + for (let i = 0; i < 20; i++) { + await tick(); + while (loaded < f.jobs.length) f.jobs[loaded++].resolve({}); + } + const columns = f.jobs.map(({ url }) => + Number(url.match(/\/(\d+)\.png$/)[1]), + ); + assert.ok(columns.includes(0) && columns.includes(1023)); + assert.ok(columns.every((x) => x >= 0 && x < 1024)); + f.destroy(); +}); diff --git a/src/globe/navara/imagery/pass.js b/src/globe/navara/imagery/pass.js index bb66d7e38..20cd8d9a3 100644 --- a/src/globe/navara/imagery/pass.js +++ b/src/globe/navara/imagery/pass.js @@ -91,17 +91,17 @@ vec3 paint(vec3 color, sampler2D image, vec4 rect, vec4 bounds, vec4 look, vec4 if ((look.y < 0.0 && x > uSplit) || (look.y > 0.0 && x < uSplit)) return color; if (!inRect(geo, rect)) return color; if (look.w > 0.5 && inRect(geo, cut)) return color; - float lon = geo.x < rect.x ? geo.x + 360.0 : geo.x; vec2 at; vec2 dx; vec2 dy; if (look.z > 0.5) { - vec2 m = vec2(merc.x + (lon - geo.x) / 360.0, merc.y); + vec2 m = vec2(merc.x < bounds.x ? merc.x + 1.0 : merc.x, merc.y); at = (m - bounds.xy) / bounds.zw; dx = mercDx / bounds.zw; dy = mercDy / bounds.zw; } else { vec2 span = vec2(bounds.z - bounds.x, bounds.y - bounds.w); + float lon = geo.x < bounds.x ? geo.x + 360.0 : geo.x; at = vec2(lon - bounds.x, geo.y - bounds.w) / span; dx = geoDx / span; dy = geoDy / span; diff --git a/src/globe/navara/input.js b/src/globe/navara/input.js index 51ad3de93..f1afc3e64 100644 --- a/src/globe/navara/input.js +++ b/src/globe/navara/input.js @@ -6,15 +6,19 @@ import { createExclusivePointer } from '../exclusivePointer.js'; // that key is held (shift over ctrl over alt), one without only while none is. // // Camera inputs map to Navara's controller flags (view.camera.options). -// Navara 0.2.1 lets a flyTo run on under user input, where Cesium cancels it; -// `onCameraInput` reports user camera input so the engine can do the same. +// Flights suspend camera inputs, matching Cesium's flight controller. import { INPUT_TYPES, normalizeInputOptions } from '../contract.js'; import { createPickDelivery } from '../pickDelivery.js'; const CLICK_PIXEL_TOLERANCE = 5; // Cesium's _clickPixelTolerance -const SCREEN_EVENTS = ['pointerdown', 'pointerup', 'pointermove', 'dblclick']; -const CAMERA_EVENTS = ['pointerdown', 'wheel', 'touchstart']; +const SCREEN_EVENTS = [ + 'pointerdown', + 'pointerup', + 'pointermove', + 'pointercancel', + 'dblclick', +]; function modifierOf(event) { if (event.shiftKey) return 'shift'; @@ -25,8 +29,7 @@ function modifierOf(event) { /** * @param {{ canvas: HTMLCanvasElement, camera: { options: object } }} view - * @param {{ onCameraInput?: (type: string) => void, - * picker?: { clickPick: Function, pick: Function, drillPick: Function }, + * @param {{ picker?: { clickPick: Function, pick: Function, drillPick: Function }, * scheduleFrame?: (run: () => void) => void }} [options] `picker` * answers listeners with { pick, drill } (picking.js): clicks from the * GPU pick of the gesture, moves from the CPU pick once a frame. @@ -34,11 +37,7 @@ function modifierOf(event) { */ export function createNavaraInput( view, - { - onCameraInput = () => {}, - picker = null, - scheduleFrame = (run) => requestAnimationFrame(run), - } = {}, + { picker = null, scheduleFrame = (run) => requestAnimationFrame(run) } = {}, ) { const { canvas } = view; const listeners = new Map(INPUT_TYPES.map((type) => [type, new Set()])); @@ -46,6 +45,7 @@ export function createNavaraInput( let destroyed = false; let cameraOptions = {}; let downAt = null; + const activePointers = new Set(); let last = null; const pointer = createExclusivePointer(); @@ -92,23 +92,37 @@ export function createNavaraInput( const position = positionOf(event); switch (event.type) { case 'pointerdown': + // A primary pointer starts a new gesture: pointers released off the + // canvas cannot hold its click back. + if (event.isPrimary === true) activePointers.clear(); + activePointers.add(event.pointerId); + if (activePointers.size > 1 || event.isPrimary === false) { + downAt = null; + break; + } if (event.button !== 0) break; - downAt = position; + downAt = { ...position, pointerId: event.pointerId }; emit('down', event, { position }); break; case 'pointerup': { + activePointers.delete(event.pointerId); if (event.button !== 0) break; emit('up', event, { position }); const start = downAt; downAt = null; if ( start && + start.pointerId === event.pointerId && Math.hypot(position.x - start.x, position.y - start.y) < CLICK_PIXEL_TOLERANCE ) emit('click', event, { position }); break; } + case 'pointercancel': + activePointers.delete(event.pointerId); + downAt = null; + break; case 'pointermove': emit('move', event, { startPosition: last ?? position, @@ -121,15 +135,6 @@ export function createNavaraInput( } last = position; }; - const onCameraEvent = (event) => { - onCameraInput(event.type); - }; - for (const type of CAMERA_EVENTS) - canvas.addEventListener(type, onCameraEvent, { - capture: true, - passive: true, - }); - let suspended = 0; const sendCameraInputs = () => { const enabled = suspended === 0; @@ -199,8 +204,6 @@ export function createNavaraInput( if (destroyed) return; pointer.destroy(); destroyed = true; - for (const type of CAMERA_EVENTS) - canvas.removeEventListener(type, onCameraEvent, { capture: true }); if (attached) for (const name of SCREEN_EVENTS) canvas.removeEventListener(name, onScreenEvent); diff --git a/src/globe/navara/input.test.mjs b/src/globe/navara/input.test.mjs index b95719564..e1f3a45ef 100644 --- a/src/globe/navara/input.test.mjs +++ b/src/globe/navara/input.test.mjs @@ -33,7 +33,6 @@ const pointer = (type, x, y, extra = {}) => function setup() { const canvas = fakeCanvas(); const cameraOptions = []; - const cameraInputs = []; const view = { canvas, camera: { @@ -42,10 +41,8 @@ function setup() { }, }, }; - const { input, destroy } = createNavaraInput(view, { - onCameraInput: (type) => cameraInputs.push(type), - }); - return { canvas, input, destroy, cameraOptions, cameraInputs }; + const { input, destroy } = createNavaraInput(view); + return { canvas, input, destroy, cameraOptions }; } test('down, up and click carry canvas-relative positions', () => { @@ -119,14 +116,9 @@ test('on validates its arguments and returns an idempotent remover', () => { assert.equal(count, 0); }); -test('suspending Navara controls preserves user input notifications', () => { - const { canvas, input, cameraOptions, cameraInputs } = setup(); - canvas.dispatchEvent(pointer('pointerdown', 1, 1)); - canvas.dispatchEvent(new Event('wheel')); - assert.deepEqual(cameraInputs, ['pointerdown', 'wheel']); +test("suspending turns Navara's controls off until the resume", () => { + const { input, cameraOptions } = setup(); const resume = input.suspendCameraInputs(); - canvas.dispatchEvent(pointer('pointerdown', 1, 1)); - assert.deepEqual(cameraInputs, ['pointerdown', 'wheel', 'pointerdown']); resume(); assert.deepEqual(cameraOptions, [ { enableSpin: false, enableZoom: false, enableTilt: false }, diff --git a/src/globe/navara/inputPinch.test.mjs b/src/globe/navara/inputPinch.test.mjs new file mode 100644 index 000000000..e379fbe44 --- /dev/null +++ b/src/globe/navara/inputPinch.test.mjs @@ -0,0 +1,104 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createNavaraInput } from './input.js'; + +function fakeCanvas() { + const canvas = new EventTarget(); + canvas.getBoundingClientRect = () => ({ left: 100, top: 50 }); + canvas.listenerCount = 0; + const add = canvas.addEventListener.bind(canvas); + const remove = canvas.removeEventListener.bind(canvas); + canvas.addEventListener = (...args) => { + canvas.listenerCount++; + add(...args); + }; + canvas.removeEventListener = (...args) => { + canvas.listenerCount--; + remove(...args); + }; + return canvas; +} + +const pointer = (type, x, y, extra = {}) => + Object.assign(new Event(type), { + clientX: x, + clientY: y, + button: 0, + shiftKey: false, + ctrlKey: false, + altKey: false, + ...extra, + }); + +function setup() { + const canvas = fakeCanvas(); + const cameraOptions = []; + const view = { + canvas, + camera: { + set options(value) { + cameraOptions.push(value); + }, + }, + }; + const { input, destroy } = createNavaraInput(view); + return { canvas, input, destroy, cameraOptions }; +} + +test('a pinch whose still finger lifts first is not a click', async () => { + const { canvas, input, destroy } = setup(); + let clicks = 0; + input.on('click', () => clicks++); + const touch = (type, id, x, y) => + pointer(type, x, y, { + pointerId: id, + pointerType: 'touch', + isPrimary: id === 1, + }); + canvas.dispatchEvent(touch('pointerdown', 1, 100, 300)); + canvas.dispatchEvent(touch('pointerdown', 2, 300, 300)); + // Only the first finger moves (a one-sided pinch). + for (let i = 1; i <= 5; i++) + canvas.dispatchEvent(touch('pointermove', 1, 100 - 20 * i, 300)); + canvas.dispatchEvent(touch('pointerup', 2, 300, 300)); + canvas.dispatchEvent(touch('pointerup', 1, 0, 300)); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(clicks, 0); + canvas.dispatchEvent(touch('pointerdown', 1, 100, 300)); + canvas.dispatchEvent(touch('pointercancel', 1, 100, 300)); + canvas.dispatchEvent(touch('pointerup', 1, 100, 300)); + await Promise.resolve(); + assert.equal(clicks, 0, 'a cancelled pointer cannot click'); + canvas.dispatchEvent(touch('pointerdown', 1, 100, 300)); + canvas.dispatchEvent(touch('pointerup', 1, 100, 300)); + await Promise.resolve(); + assert.equal(clicks, 1, 'the next single tap still clicks'); + destroy(); +}); + +test('a mouse released off the canvas does not hold back the next tap', async () => { + const { canvas, input, destroy } = setup(); + let clicks = 0; + input.on('click', () => clicks++); + // The mouse goes down on the globe and up outside it. + canvas.dispatchEvent( + pointer('pointerdown', 100, 300, { pointerId: 1, isPrimary: true }), + ); + canvas.dispatchEvent( + pointer('pointerdown', 100, 300, { + pointerId: 7, + pointerType: 'touch', + isPrimary: true, + }), + ); + canvas.dispatchEvent( + pointer('pointerup', 100, 300, { + pointerId: 7, + pointerType: 'touch', + isPrimary: true, + }), + ); + await Promise.resolve(); + assert.equal(clicks, 1); + destroy(); +}); diff --git a/src/globe/navara/maps.js b/src/globe/navara/maps.js index 1dd0bb125..b99cfc5c7 100644 --- a/src/globe/navara/maps.js +++ b/src/globe/navara/maps.js @@ -13,9 +13,9 @@ // - photoreal: Google 3D tiles with a browser key only (no server proxy), // with Google's logo and the tiles' own // copyright lines (creditLayerId) on screen. Showing them hides the base -// imagery, as MapSourceController removes it; clearing them does not bring -// it back until setBaseImagery. Hidden, not deleted: deleting the last -// raster layer stops Navara 0.2.1 loading terrain at the current view. +// imagery; clearing them restores the last base imagery and its credit. +// Hidden, not deleted: deleting the last raster layer stops Navara 0.2.1 +// loading terrain at the current view. import { SENTINEL2_CREDIT, @@ -124,6 +124,8 @@ export function createNavaraMaps( // call made last decides what shows. const generations = { display: 0, terrain: 0 }; let destroyed = false; + // maps.terrainRevision: one more for every terrain shown or cleared. + let terrainRevision = 0; function retire(slot) { if (!slot) return; @@ -137,6 +139,7 @@ export function createNavaraMaps( const slot = slots[name]; slots[name] = null; retire(slot); + if (name === 'terrain' && slot) terrainRevision++; } // The new layer goes in before the old one goes: deleting the last raster @@ -150,6 +153,7 @@ export function createNavaraMaps( typeof credit === 'function' ? credit(layer) : credit, ); retire(previous); + if (name === 'terrain') terrainRevision++; if (shown.length) view.attribution?.add(shown); slots[name] = { key, @@ -204,6 +208,7 @@ export function createNavaraMaps( type: 'quantized-mesh', url: `${endpoint.url}{z}/{x}/{y}.terrain?v=1.2.0`, token: endpoint.accessToken, + attributions: endpoint.attributions ?? [], }; } default: @@ -249,7 +254,16 @@ export function createNavaraMaps( return; } if (slots.terrain?.key === description.url) return; - show('terrain', description.url, description, { type: 'terrain' }); + const { attributions = [], ...sourceDesc } = description; + show( + 'terrain', + description.url, + sourceDesc, + { type: 'terrain' }, + attributions.map(({ html }) => ({ attributionHtml: html })), + ); + if (attributions.some(({ collapsible }) => collapsible === false)) + view.attribution?.show(); }, async setPhotorealTiles(source) { const generation = ++generations.display; @@ -329,10 +343,29 @@ export function createNavaraMaps( }; }, current: () => ({ baseImagery: currentBase, photoreal: tilesSource }), + terrainRevision: () => terrainRevision, }; return { maps, + /** + * surface.sampleTerrainMostDetailed: the terrain shown sampled at its + * finest level (Navara fetches the tiles); 0 on the ellipsoid. + */ + async sampleTerrain(positions) { + const source = slots.terrain?.source; + if (!source) return positions.map(() => 0); + const sampled = await view.sampleTerrainMostDetailed( + source, + positions.map((place) => ({ + lat: (place.latitude * 180) / Math.PI, + lng: (place.longitude * 180) / Math.PI, + })), + ); + return sampled.map(({ height }) => + Number.isFinite(height) ? height : undefined, + ); + }, destroy() { if (destroyed) return; destroyed = true; diff --git a/src/globe/navara/maps.test.mjs b/src/globe/navara/maps.test.mjs index 2b8c292af..44a79cc37 100644 --- a/src/globe/navara/maps.test.mjs +++ b/src/globe/navara/maps.test.mjs @@ -392,3 +392,49 @@ test('a tile overlay stays above the base imagery across map switches and credit remove(); destroy(); }); + +test('world terrain publishes endpoint credits and removes them on replacement', async () => { + const view = fakeView(); + const { maps, destroy } = createNavaraMaps(view, { + fetch: async () => ({ + ok: true, + json: async () => ({ + url: 'https://terrain.example/', + accessToken: 'temporary', + attributions: [{ html: 'Terrain provider', collapsible: false }], + }), + }), + }); + await maps.setTerrain({ kind: 'world', accessToken: 'configured' }); + assert.ok( + view.shown.some((credit) => credit.attributionHtml === 'Terrain provider'), + ); + assert.ok(view.opened > 0); + await maps.setTerrain({ kind: 'ellipsoid' }); + assert.deepEqual(view.shown, []); + destroy(); +}); + +test('terrainRevision counts terrain shown or cleared; sampling reads the shown source', async () => { + const view = fakeView(); + const sampled = []; + view.sampleTerrainMostDetailed = async (source, positions) => { + sampled.push({ source, positions }); + return positions.map(({ lat, lng }) => ({ lat, lng, height: lat + lng })); + }; + const navara = createNavaraMaps(view); + const { maps } = navara; + const place = { longitude: Math.PI / 4, latitude: Math.PI / 6, height: 0 }; + assert.deepEqual(await navara.sampleTerrain([place]), [0], 'the ellipsoid'); + await maps.setTerrain({ kind: 'keyless' }); + assert.equal(maps.terrainRevision(), 1); + const [height] = await navara.sampleTerrain([place]); + assert.ok(Math.abs(height - 75) < 1e-9); + assert.equal(sampled[0].source, layersOf(view, 'terrain')[0].desc.source); + await maps.setTerrain({ kind: 'keyless' }); + assert.equal(maps.terrainRevision(), 1, 'the same terrain again'); + await maps.setTerrain({ kind: 'ellipsoid' }); + assert.equal(maps.terrainRevision(), 2); + await maps.setTerrain({ kind: 'ellipsoid' }); + assert.equal(maps.terrainRevision(), 2, 'nothing to clear'); +}); diff --git a/src/globe/navara/movers/index.js b/src/globe/navara/movers/index.js index b1da819fa..daba85972 100644 --- a/src/globe/navara/movers/index.js +++ b/src/globe/navara/movers/index.js @@ -130,6 +130,7 @@ export function createNavaraMovers(view, { overlay, aces = false }) { ); return { ready: mover.ready, + failed: mover.failed, commit() { mover.commit(store.dirty()); request(); @@ -168,6 +169,7 @@ export function createNavaraMovers(view, { overlay, aces = false }) { }); return { ready: mover.ready, + failed: mover.failed, pickRadiusPx: mover.pickRadiusPx, commit() { mover.commit(store.dirty()); diff --git a/src/globe/navara/movers/modelMover.js b/src/globe/navara/movers/modelMover.js index 5e39f2647..7473b2a81 100644 --- a/src/globe/navara/movers/modelMover.js +++ b/src/globe/navara/movers/modelMover.js @@ -199,10 +199,14 @@ export function createModelMover( }; const group = { meshes: [], pickMeshes: [], ready: null }; + let failed = false; const asset = load(model.url).catch(() => { // A missing asset keeps the layer's glyph fallback. Do not leak the // rejected loader promise (or its URL) through descriptor creation. - if (!disposed) console.warn('[globe] Model asset unavailable'); + if (!disposed) { + failed = true; + console.warn('[globe] Model asset unavailable'); + } return null; }); group.ready = asset.then((gltf) => { @@ -259,7 +263,6 @@ export function createModelMover( overlay.add(tint); } materials.push(material); - if (tint) materials.push(tint.material); parts.push({ mesh, tint, local: child.matrixWorld.clone() }); group.meshes.push(mesh); group.pickMeshes.push(tint ?? mesh); @@ -288,6 +291,7 @@ export function createModelMover( group, uniforms, ready: () => loaded, + failed: () => failed, origin: () => origin, /** * The drawn radius (px) of an instance at its last write, or null when diff --git a/src/globe/navara/movers/modelMover.test.mjs b/src/globe/navara/movers/modelMover.test.mjs index 287a2888a..3a837b715 100644 --- a/src/globe/navara/movers/modelMover.test.mjs +++ b/src/globe/navara/movers/modelMover.test.mjs @@ -204,6 +204,7 @@ test('a failed model load stays not-ready without an unhandled rejection or leak }); await mover.group.ready; assert.equal(mover.ready(), false); + assert.equal(mover.failed(), true); assert.equal(mover.group.meshes.length, 0); assert.deepEqual(warnings, ['[globe] Model asset unavailable']); mover.dispose(); @@ -278,12 +279,21 @@ test('display tint shares transforms, preserves fade opacity and leaves no overl shader.fragmentShader, /mix\(gevLitSrgb, vGevColor.rgb, uGevBlend\)/, ); + assert.match(shader.vertexShader, /vGevSonarFactor = gevSonarFactor/); + assert.match(shader.fragmentShader, /gevSonarColor\(vec4\(mix/); assert.equal(base.material.colorWrite, false); assert.equal( mover.group.pickMeshes[0], tint, 'GPU picking keeps its color-writing material', ); + store.set(0, { color: [1, 1, 1, 1] }); + mover.commit(store.dirty()); + assert.equal( + tint.material.transparent, + true, + 'sonar can fade an opaque model', + ); store.setCount(0); mover.commit(store.dirty()); assert.equal(tint.count, 0); diff --git a/src/globe/navara/movers/modelTint.js b/src/globe/navara/movers/modelTint.js index f2c879fd3..3509d78c7 100644 --- a/src/globe/navara/movers/modelTint.js +++ b/src/globe/navara/movers/modelTint.js @@ -2,6 +2,7 @@ // the view's HDR effects. This keeps MSAA from spreading HDR tint into edges. import { InstancedMesh } from 'three'; import { OVERLAY_DEPTH_FRAGMENT } from '../overlayDepth.js'; +import { DETACHED_SONAR, SONAR_VERTEX } from '../sonar.js'; // three's pinned ACESFilmicToneMapping, with the view's public exposure. const ACES = ` @@ -18,6 +19,7 @@ vec3 gevModelAces(vec3 color) { export function createModelTint(mesh, uniforms, overlayUniforms) { const material = mesh.material.clone(); + material.transparent = true; material.depthTest = false; material.depthWrite = false; material.toneMapped = false; @@ -25,22 +27,22 @@ export function createModelTint(mesh, uniforms, overlayUniforms) { ? 'gl_FragColor.rgb' : 'gevModelAces(gl_FragColor.rgb)'; material.onBeforeCompile = (shader) => { - Object.assign(shader.uniforms, uniforms, overlayUniforms, { + Object.assign(shader.uniforms, DETACHED_SONAR, uniforms, overlayUniforms, { uGevDepthMode: { value: 1 }, }); shader.vertexShader = shader.vertexShader .replace( 'void main() {', - 'attribute vec4 gevColor;\nvarying vec4 vGevColor;\nvarying float vGevDepthFree;\nvoid main() {', + `attribute vec4 gevColor;\nvarying vec4 vGevColor;\nvarying float vGevDepthFree;\nvarying float vGevSonarFactor;\nuniform vec2 uGevResolution;\n${SONAR_VERTEX}\nvoid main() {`, ) .replace( '#include ', - '#include \nvGevColor = gevColor;\nvGevDepthFree = 0.0;', + '#include \nvGevColor = gevColor;\nvGevDepthFree = 0.0;\nvGevSonarFactor = gevSonarFactor(projectionMatrix * modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0), uGevResolution);', ); shader.fragmentShader = shader.fragmentShader .replace( 'void main() {', - `uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\n${ACES}\n${OVERLAY_DEPTH_FRAGMENT}\nvoid main() {`, + `uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\n${ACES}\nvarying float vGevSonarFactor;\n${SONAR_VERTEX}\n${OVERLAY_DEPTH_FRAGMENT}\nvoid main() {`, ) .replace( '#include ', @@ -56,7 +58,7 @@ export function createModelTint(mesh, uniforms, overlayUniforms) { ) .replace( '#include ', - `#include \nvec3 gevLitSrgb = sRGBTransferOETF(vec4(${lit}, 1.0)).rgb;\ngl_FragColor.rgb = sRGBTransferEOTF(vec4(mix(gevLitSrgb, vGevColor.rgb, uGevBlend), 1.0)).rgb;`, + `#include \nvec3 gevLitSrgb = sRGBTransferOETF(vec4(${lit}, 1.0)).rgb;\nvec4 gevContact = gevSonarColor(vec4(mix(gevLitSrgb, vGevColor.rgb, uGevBlend), gl_FragColor.a), vGevSonarFactor);\ngl_FragColor = vec4(sRGBTransferEOTF(gevContact).rgb, gevContact.a);`, ); }; material.customProgramCacheKey = () => 'gev-model-lit-display-color'; diff --git a/src/globe/navara/movers/moverMesh.js b/src/globe/navara/movers/moverMesh.js index e4fc34e9c..cb32f21fb 100644 --- a/src/globe/navara/movers/moverMesh.js +++ b/src/globe/navara/movers/moverMesh.js @@ -227,24 +227,28 @@ function cesiumSampling(texture, encoded = false) { } /** The atlas as a three texture (a canvas at once, a URL once loaded). */ -function atlasTexture(atlas, encoded, onLoad) { +function atlasTexture(atlas, encoded, onLoad, onError) { if (atlas.canvas) return cesiumSampling(new CanvasTexture(atlas.canvas), encoded); const texture = cesiumSampling(new Texture(), encoded); const image = new Image(); image.crossOrigin = 'anonymous'; const release = () => { - image.onload = null; + image.onload = image.onerror = null; texture.image = null; texture.removeEventListener('dispose', release); }; texture.addEventListener('dispose', release); image.onload = () => { - image.onload = null; + image.onload = image.onerror = null; texture.image = image; texture.needsUpdate = true; onLoad(); }; + image.onerror = () => { + image.onload = image.onerror = null; + onError(); + }; image.src = atlas.url; return texture; } @@ -313,14 +317,22 @@ export function createMoverMesh( }; // A canvas atlas is there at once; a URL one once its image loads. let textureReady = point || Boolean(options.sprite.atlas.canvas); + let textureFailed = false; const encodedAtlas = !point && options.sharpen === true; const material = new MeshBasicMaterial({ map: point ? null - : atlasTexture(options.sprite.atlas, encodedAtlas, () => { - textureReady = true; - onTextureLoad(); - }), + : atlasTexture( + options.sprite.atlas, + encodedAtlas, + () => { + textureReady = true; + onTextureLoad(); + }, + () => { + textureFailed = true; + }, + ), transparent: true, alphaTest: point || encodedAtlas ? 0.005 : 0.02, // Drawn after tone mapping, with no depth buffer: the shader tests the @@ -382,6 +394,7 @@ export function createMoverMesh( mesh, uniforms, ready: () => textureReady, + failed: () => textureFailed, origin: () => origin, /** Writes the changed instances; the origin starts at the first one. */ commit(range) { diff --git a/src/globe/navara/overlayPass.js b/src/globe/navara/overlayPass.js index d6be5dc6a..fe8530a64 100644 --- a/src/globe/navara/overlayPass.js +++ b/src/globe/navara/overlayPass.js @@ -165,6 +165,17 @@ export class GevOverlayPass extends Pass { resolution[0], resolution[1], ); + if (signature) { + // The sonar's uniforms change the glyphs only while it applies; its + // angle moves every frame even then, so it is left out otherwise. + const uniforms = this.overlay.uniforms; + const enabled = uniforms.uGevSonarEnabled?.value >= 0.5; + const [angle, range, floor, active] = uniforms.uGevSonar?.value ?? []; + signature.push(enabled); + if (enabled) signature.push(floor, active); + if (enabled && active >= 0.5) + signature.push(angle, range, uniforms.uGevSonarSector?.value); + } const targetWidth = resolution[0]; const targetHeight = resolution[1]; if ( diff --git a/src/globe/navara/overlaySonar.test.mjs b/src/globe/navara/overlaySonar.test.mjs new file mode 100644 index 000000000..1efd84317 --- /dev/null +++ b/src/globe/navara/overlaySonar.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + Scene, + PerspectiveCamera, + HalfFloatType, + NoColorSpace, + SRGBColorSpace, + InstancedMesh, + MeshBasicMaterial, + PlaneGeometry, + Texture, +} from 'three'; +import { GevOverlayPass } from './overlayPass.js'; + +function harness() { + let active = false, + target = null, + clearColor = 0x123456, + clearAlpha = 1; + const overlay = { + displayBlend: true, + hasDrawables: () => active, + uniforms: { + uGevResolution: { value: [0, 0] }, + uGevDisplayBlend: { value: 0 }, + uGevSonar: { value: [0, 1, 0.84, 1] }, + uGevSonarSector: { value: 24 }, + uGevSonarEnabled: { value: 1 }, + }, + }; + const scene = new Scene(), + pass = new GevOverlayPass(scene, new PerspectiveCamera(), overlay), + draws = []; + const renderer = { + domElement: new EventTarget(), + autoClear: true, + outputColorSpace: SRGBColorSpace, + getPixelRatio: () => 1, + getClearColor(color) { + return color.set(clearColor); + }, + getClearAlpha() { + return clearAlpha; + }, + setClearColor(color, alpha) { + clearColor = color.isColor ? color.getHex() : color; + clearAlpha = alpha; + }, + clear() {}, + get clearAlpha() { + return clearAlpha; + }, + setRenderTarget(value) { + target = value; + }, + render(object) { + draws.push({ + object, + target, + displayBlend: overlay.uniforms.uGevDisplayBlend.value, + }); + }, + }; + return { + overlay, + scene, + pass, + draws, + renderer, + setActive(value) { + active = value; + }, + get target() { + return target; + }, + }; +} +test('static sprite glyphs are drawn again when the Cyber sonar moves', () => { + const h = harness(), + input = { width: 1280, height: 800, texture: {} }, + output = {}, + mesh = new InstancedMesh( + new PlaneGeometry(1, 1), + new MeshBasicMaterial({ map: new Texture() }), + 1, + ); + mesh.userData.gevStaticSprite = true; + mesh.count = 1; + h.scene.add(mesh); + h.setActive(true); + h.pass.render(h.renderer, input, output); + const drawn = () => h.draws.filter((draw) => draw.object === h.scene).length; + assert.equal(drawn(), 1); + // The sweep turns (sonar.js writes the shared uniform each frame). + h.overlay.uniforms.uGevSonar.value[0] = 90; + h.pass.render(h.renderer, input, output); + assert.equal(drawn(), 2, 'a new sonar angle draws the glyphs again'); + h.overlay.uniforms.uGevSonarSector.value = 60; + h.pass.render(h.renderer, input, output); + assert.equal(drawn(), 3, 'a new sector draws the glyphs again'); + // The Cyber treatment is turned off. + h.overlay.uniforms.uGevSonarEnabled.value = 0; + h.pass.render(h.renderer, input, output); + assert.equal(drawn(), 4, 'sonar off draws the glyphs again'); + // Off, the angle still moves every frame; the glyphs stay cached. + h.overlay.uniforms.uGevSonar.value[0] = 180; + h.pass.render(h.renderer, input, output); + assert.equal(drawn(), 4, 'the angle of an unused sonar draws nothing'); +}); diff --git a/src/globe/navara/sonar.js b/src/globe/navara/sonar.js index d9b7e4440..bf5169260 100644 --- a/src/globe/navara/sonar.js +++ b/src/globe/navara/sonar.js @@ -9,7 +9,7 @@ // colour pulled toward the scanner tint by that factor and its alpha scaled // by it. // Positions, picks and the instance data stay as the layers set them. -// Models keep their colours (Cesium tints them on the CPU). +// Models apply the same treatment after lighting (movers/modelTint.js). const views = new WeakMap(); diff --git a/src/globe/navara/surface.js b/src/globe/navara/surface.js index 02f804494..1970b3e1b 100644 --- a/src/globe/navara/surface.js +++ b/src/globe/navara/surface.js @@ -234,9 +234,15 @@ const tileSizeOf = (root) => { * isIdle: () => boolean, * retainTileGeometry: () => void, * }} hooks Navara internals (internals.js). + * @param {(positions: object[]) => Promise<(number | undefined)[]>} [sampleTerrain] + * The maps' sampling of the terrain shown (maps.js). * @returns {{ surface: import('../contract.js').GlobeSurface }} */ -export function createNavaraSurface(view, hooks) { +export function createNavaraSurface( + view, + hooks, + sampleTerrain = async (positions) => positions.map(() => 0), +) { let supported = true; try { hooks.retainTileGeometry(); @@ -342,6 +348,7 @@ export function createNavaraSurface(view, hooks) { supported, terrainHeight, terrainHeightSupported: true, + sampleTerrainMostDetailed: (positions) => sampleTerrain(positions), clampToHeight(position, exclude) { const place = cartographic.fromCartesian(position, WGS84); if (!place) return undefined; diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 69ba2c4d3..6599a8b16 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -1490,6 +1490,31 @@ export function createContractSuite( ensure(outcome.join() === 'cancel', `flight ended with ${outcome}`); }, ], + [ + 'camera.setView: cancels an active flight and retains its destination', + async () => { + await nadirView(); + const outcome = []; + camera.flyTo({ + destination: cartesian3.fromDegrees(100, 0, 5e6), + duration: 5, + complete: () => outcome.push('complete'), + cancel: () => outcome.push('cancel'), + }); + await settle(2); + const destination = cartesian3.fromDegrees(20, 10, 200000); + camera.setView({ destination }); + await settle(3); + ensure(outcome.join() === 'cancel', `flight ended with ${outcome}`); + ensure(!camera.flightActive(), 'setView left the flight active'); + near( + cartesian3.distance(camera.pose().position, destination), + 0, + 0.01, + 'setView position error (m)', + ); + }, + ], [ 'camera.flyTo: a newer flight cancels the previous one', async () => { diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index f4c698251..76432aea7 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -77,9 +77,11 @@ const SCRIPTABLE = new Set([ 'sampleHeight', 'sampleHeightDetailed', 'terrainHeight', + 'sampleTerrainMostDetailed', 'clampToHeight', 'tilesLoaded', 'worldToWindow', + 'moverFailed', ]); const EASINGS = { linear: (t) => t, @@ -254,13 +256,16 @@ export function createFakeEngine(options = {}) { }; }); // Movers keep their store for tests; commit counts as a render request. - const moverRegistry = createMoverRegistry(() => { + const moverRegistry = createMoverRegistry((options) => { const part = { commits: 0, commit() { part.commits++; fake.renderRequests++; }, + // Scripted as 'moverFailed' (set id, index) for a load that fails. + failed: (index) => + scripted('moverFailed', [options.id, index], () => false), destroy() {}, }; return part; @@ -340,7 +345,14 @@ export function createFakeEngine(options = {}) { renderRequests: 0, cameraInputsEnabled: true, credits: [], - maps: { imagery: undefined, terrain: undefined, photoreal: null }, + maps: { + imagery: undefined, + terrain: undefined, + photoreal: null, + terrainRevision: 0, + }, + // render.maxTextureSize: a common WebGL2 limit; tests set a smaller one. + maxTextureSize: 4096, destroyed: false, }; const record = (method, args) => fake.calls.push({ method, args }); @@ -523,6 +535,7 @@ export function createFakeEngine(options = {}) { command('camera.setView', [view]); if (!view?.destination) throw new TypeError('setView needs a destination'); + cancelCurrentFlight(); apply(viewFor(view.destination, view.orientation, CESIUM_DEFAULTS)); }, flyTo(flightOptions) { @@ -780,6 +793,7 @@ export function createFakeEngine(options = {}) { continuous = Boolean(enabled); }, isContinuous: () => continuous, + maxTextureSize: () => fake.maxTextureSize, setSharpen(options) { record('render.setSharpen', [options]); sharpen = mergeSharpen(sharpen, options); @@ -823,6 +837,14 @@ export function createFakeEngine(options = {}) { return scripted('terrainHeight', [position], () => 0); }, terrainHeightSupported: true, + async sampleTerrainMostDetailed(positions) { + record('surface.sampleTerrainMostDetailed', [positions]); + return scripted('sampleTerrainMostDetailed', [positions], () => + positions.map((position) => + scripted('terrainHeight', [position], () => 0), + ), + ); + }, clampToHeight(position, exclude) { record('surface.clampToHeight', [position, exclude]); return scripted('clampToHeight', [position, exclude], () => undefined); @@ -894,6 +916,7 @@ export function createFakeEngine(options = {}) { ) throw new TypeError(`Unknown terrain source: ${kind}`); fake.maps.terrain = source; + fake.maps.terrainRevision++; }, async setPhotorealTiles(source) { record('maps.setPhotorealTiles', [source]); @@ -914,6 +937,7 @@ export function createFakeEngine(options = {}) { baseImagery: fake.maps.imagery ?? null, photoreal: fake.maps.photoreal, }), + terrainRevision: () => fake.maps.terrainRevision, }, destroy() { record('destroy', []); diff --git a/src/hud.js b/src/hud.js index 5d0091ba3..789ae78ac 100644 --- a/src/hud.js +++ b/src/hud.js @@ -561,7 +561,7 @@ export class IntelHUD { // i18n-ignore: sensor codes readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`; if (this.engine?.host.container?.hasAttribute?.('data-terrain-daylight')) - readouts.alt += ' · DAYLIGHT VIEW'; + readouts.alt += ` · ${t('DAYLIGHT VIEW')}`; // Collection timestamp const now = new Date(); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index f0d5f49d9..d06b1e649 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3471,5 +3471,12 @@ "Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة", "event age filter|All": "الكل", "Event age": "عمر الحدث", - "Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ" + "Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ", + "Data layers: {layers}": "طبقات البيانات: {layers}", + "No registered data layers": "لا توجد طبقات بيانات مسجّلة", + "DAYLIGHT VIEW": "عرض نهاري", + "Globe engine failed to start. Running {engine}: {message}": "تعذّر تشغيل محرك الكرة الأرضية. يعمل الآن {engine}: {message}", + "Bing imagery is not available on the experimental Navara engine": "صور Bing غير متاحة في محرك Navara التجريبي", + "NOT ON THIS ENGINE": "غير متاح في هذا المحرك", + "Kept in the link: {layers}": "محفوظة في الرابط: {layers}" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 3a017195c..873a808cb 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3171,5 +3171,12 @@ "Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben", "event age filter|All": "Alle", "Event age": "Ereignisalter", - "Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben" + "Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben", + "Data layers: {layers}": "Datenebenen: {layers}", + "No registered data layers": "Keine registrierten Datenebenen", + "DAYLIGHT VIEW": "TAGESLICHTANSICHT", + "Globe engine failed to start. Running {engine}: {message}": "Die Globus-Engine konnte nicht starten. Es läuft {engine}: {message}", + "Bing imagery is not available on the experimental Navara engine": "Bing-Bildmaterial ist in der experimentellen Navara-Engine nicht verfügbar", + "NOT ON THIS ENGINE": "NICHT IN DIESER ENGINE", + "Kept in the link: {layers}": "Im Link beibehalten: {layers}" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ecc5fb698..239cce022 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3246,5 +3246,12 @@ "Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha", "event age filter|All": "Todos", "Event age": "Antigüedad", - "Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha" + "Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha", + "Data layers: {layers}": "Capas de datos: {layers}", + "No registered data layers": "No hay capas de datos registradas", + "DAYLIGHT VIEW": "VISTA DIURNA", + "Globe engine failed to start. Running {engine}: {message}": "No se pudo iniciar el motor del globo. Se usa {engine}: {message}", + "Bing imagery is not available on the experimental Navara engine": "Las imágenes de Bing no están disponibles en el motor experimental Navara", + "NOT ON THIS ENGINE": "NO EN ESTE MOTOR", + "Kept in the link: {layers}": "Se conservan en el enlace: {layers}" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 490c2acd3..36256617f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3246,5 +3246,12 @@ "Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés", "event age filter|All": "Tous", "Event age": "Ancienneté", - "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés" + "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés", + "Data layers: {layers}": "Couches de données : {layers}", + "No registered data layers": "Aucune couche de données enregistrée", + "DAYLIGHT VIEW": "VUE DE JOUR", + "Globe engine failed to start. Running {engine}: {message}": "Le moteur du globe n’a pas pu démarrer. {engine} est utilisé : {message}", + "Bing imagery is not available on the experimental Navara engine": "L’imagerie Bing n’est pas disponible avec le moteur expérimental Navara", + "NOT ON THIS ENGINE": "PAS SUR CE MOTEUR", + "Kept in the link: {layers}": "Conservées dans le lien : {layers}" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 428b89bd0..c986bbc44 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3246,5 +3246,12 @@ "Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti", "event age filter|All": "Tutti", "Event age": "Età degli eventi", - "Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti" + "Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti", + "Data layers: {layers}": "Livelli dati: {layers}", + "No registered data layers": "Nessun livello dati registrato", + "DAYLIGHT VIEW": "VISTA DIURNA", + "Globe engine failed to start. Running {engine}: {message}": "Impossibile avviare il motore del globo. È in uso {engine}: {message}", + "Bing imagery is not available on the experimental Navara engine": "Le immagini Bing non sono disponibili nel motore sperimentale Navara", + "NOT ON THIS ENGINE": "NON IN QUESTO MOTORE", + "Kept in the link: {layers}": "Conservati nel link: {layers}" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9d9ca24e5..4657ac0ef 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3096,5 +3096,12 @@ "Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持", "event age filter|All": "すべて", "Event age": "経過日数", - "Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持" + "Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持", + "Data layers: {layers}": "データレイヤー:{layers}", + "No registered data layers": "登録済みのデータレイヤーはありません", + "DAYLIGHT VIEW": "昼光ビュー", + "Globe engine failed to start. Running {engine}: {message}": "地球儀エンジンを起動できませんでした。{engine} で実行中:{message}", + "Bing imagery is not available on the experimental Navara engine": "Bing画像は実験版 Navara エンジンでは利用できません", + "NOT ON THIS ENGINE": "このエンジンでは非対応", + "Kept in the link: {layers}": "リンクに保持:{layers}" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 8764f136b..43bf90e36 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3246,5 +3246,12 @@ "Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos", "event age filter|All": "Todos", "Event age": "Idade do evento", - "Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos" + "Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos", + "Data layers: {layers}": "Camadas de dados: {layers}", + "No registered data layers": "Nenhuma camada de dados registrada", + "DAYLIGHT VIEW": "VISTA DIURNA", + "Globe engine failed to start. Running {engine}: {message}": "Não foi possível iniciar o motor do globo. Usando {engine}: {message}", + "Bing imagery is not available on the experimental Navara engine": "As imagens do Bing não estão disponíveis no motor experimental Navara", + "NOT ON THIS ENGINE": "NÃO NESTE MOTOR", + "Kept in the link: {layers}": "Mantidas no link: {layers}" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index eab70a251..ea48ce635 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3321,5 +3321,12 @@ "Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены", "event age filter|All": "Все", "Event age": "Возраст события", - "Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены" + "Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены", + "Data layers: {layers}": "Слои данных: {layers}", + "No registered data layers": "Нет зарегистрированных слоёв данных", + "DAYLIGHT VIEW": "ДНЕВНОЙ ВИД", + "Globe engine failed to start. Running {engine}: {message}": "Не удалось запустить движок глобуса. Работает {engine}: {message}", + "Bing imagery is not available on the experimental Navara engine": "Снимки Bing недоступны в экспериментальном движке Navara", + "NOT ON THIS ENGINE": "НЕТ В ЭТОМ ДВИЖКЕ", + "Kept in the link: {layers}": "Сохранены в ссылке: {layers}" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 4c40f7fe0..7dde7ec4b 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3321,5 +3321,12 @@ "Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено", "event age filter|All": "Усі", "Event age": "Вік події", - "Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено" + "Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено", + "Data layers: {layers}": "Шари даних: {layers}", + "No registered data layers": "Немає зареєстрованих шарів даних", + "DAYLIGHT VIEW": "ДЕННИЙ ВИГЛЯД", + "Globe engine failed to start. Running {engine}: {message}": "Не вдалося запустити рушій глобуса. Працює {engine}: {message}", + "Bing imagery is not available on the experimental Navara engine": "Знімки Bing недоступні в експериментальному рушії Navara", + "NOT ON THIS ENGINE": "НЕМАЄ В ЦЬОМУ РУШІЇ", + "Kept in the link: {layers}": "Збережено в посиланні: {layers}" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 5ac570480..b6f49a375 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3096,5 +3096,12 @@ "Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记", "event age filter|All": "全部", "Event age": "事件时间", - "Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记" + "Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记", + "Data layers: {layers}": "数据图层:{layers}", + "No registered data layers": "没有已注册的数据图层", + "DAYLIGHT VIEW": "日光视图", + "Globe engine failed to start. Running {engine}: {message}": "地球渲染引擎启动失败。当前运行 {engine}:{message}", + "Bing imagery is not available on the experimental Navara engine": "实验性 Navara 引擎不支持 Bing 影像", + "NOT ON THIS ENGINE": "此引擎不支持", + "Kept in the link: {layers}": "已保留在链接中:{layers}" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 43fc20020..a6e70fa6c 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3096,5 +3096,12 @@ "Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記", "event age filter|All": "全部", "Event age": "事件時間", - "Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記" + "Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記", + "Data layers: {layers}": "數據圖層:{layers}", + "No registered data layers": "沒有已註冊的數據圖層", + "DAYLIGHT VIEW": "日光視圖", + "Globe engine failed to start. Running {engine}: {message}": "地球渲染引擎啟動失敗。當前運行 {engine}:{message}", + "Bing imagery is not available on the experimental Navara engine": "實驗性 Navara 引擎不支持 Bing 影像", + "NOT ON THIS ENGINE": "此引擎不支持", + "Kept in the link: {layers}": "已保留在鏈接中:{layers}" } diff --git a/src/layers/aircraft/render/cockpitContactDot.test.mjs b/src/layers/aircraft/render/cockpitContactDot.test.mjs new file mode 100644 index 000000000..fbb46da88 --- /dev/null +++ b/src/layers/aircraft/render/cockpitContactDot.test.mjs @@ -0,0 +1,68 @@ +// In the cockpit, far contacts switch their sprite to cockpitContactDotImage() +// (src/layers/flights/rendering.js, military/rendering.js). The sprite must +// then draw the dot, not keep the aircraft silhouette cell it had before. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../../geo/index.js'; +import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; +import { aircraftIcon } from '../../../data/aircraftIcons.js'; +import { cockpitContactDotImage } from '../../../data/cockpitContactDot.js'; +import { aircraftAtlasLayout, createAircraftDisplay } from './display.js'; + +// cockpitContactDotImage() draws on a canvas; a stub document gives it a stable data URL. +globalThis.document ??= { + createElement: () => ({ + getContext: () => new Proxy({}, { get: () => () => {}, set: () => true }), + toDataURL: () => 'data:image/png;base64,COCKPITDOT', + }), +}; + +const layout = aircraftAtlasLayout(); +const drawAtlas = async () => ({ + canvas: { width: layout.width, height: layout.height }, + cells: layout.cells, +}); +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('a cockpit contact draws the contact dot, not the aircraft silhouette', async () => { + const engine = createFakeEngine(); + const display = createAircraftDisplay(engine, { + layerId: 'flights', + order: 8, + headingOffsetDeg: 180, + drawAtlas, + }); + await display.whenReady(); + display.fleet.attach(); + const sprite = display.fleet.add({ + position: geo.cartesian3.fromDegrees(-97.7, 30.2, 3000), + image: aircraftIcon('helicopter'), + width: 20, + height: 20, + scale: 1, + rotation: 0, + color: { red: 1, green: 1, blue: 1, alpha: 1 }, + id: 'abc123', + show: true, + }); + await settle(); + const cellOf = () => { + const { store } = engine.fake.moverSet(sprite.gevBank.set.id); + return store.cellKeys[store.cells[0]]; + }; + assert.equal(cellOf(), 'helicopter@64'); + sprite.image = cockpitContactDotImage(); + sprite.width = 8; + sprite.height = 8; + engine.fake.frames(2); + await settle(); + assert.equal(cellOf(), 'cockpit-dot'); + assert.equal( + engine.fake.moverSet(sprite.gevBank.set.id).options.sprite.atlas.url, + cockpitContactDotImage(), + ); + sprite.image = aircraftIcon('helicopter'); + assert.equal(cellOf(), 'helicopter@64'); + display.destroy(); + engine.destroy(); +}); diff --git a/src/layers/aircraft/render/contract.js b/src/layers/aircraft/render/contract.js index 4505f9236..7532d902e 100644 --- a/src/layers/aircraft/render/contract.js +++ b/src/layers/aircraft/render/contract.js @@ -18,7 +18,9 @@ // scaleByDistance as the engine's own copy). The logic may keep its own // bookkeeping fields on a handle (prefixed _gev). // ModelHandle (models.load*) — one glTF instance: -// show, ready (read-only: placed, and its model loaded), scale, +// show, ready (read-only: placed, and its model loaded), failed +// (read-only: its model would not load; a tracked one is loaded again +// after models.remove), scale, // computedScale (read-only: scale after minimumPixelSize), color, id, // modelMatrix (16 numbers, column-major, the model frame: written by // models.setPose and read for anchors). @@ -61,7 +63,7 @@ * setVisible(bool), add(spec) → SpriteHandle, remove(sprite), * restoreOrder(), restoreOrderOnEnable(). * @property {object} models live(), setVisible(bool), loadFleet(spec), - * loadTracked(spec), preload(url) → Promise, add(model), + * loadTracked(spec) → Promise, add(model), * remove(model), destroy(model), setPose(model, position, headingDeg, * offsetDeg?) (offsetDeg: the model's nose offset, default the layer's). * spec: { url, minimumPixelSize, scale, radiusM, color, blendAmount, @@ -71,7 +73,7 @@ * billboardSize(handle) → { width, height, scale, scaleByDistance } | null. * spec: { image, width, height, scale, scaleByDistance, * disableDepthTestDistance, position(), color() → colour, rotation() }. - * @property {object} follow start(handle) → stop, refocus(handle) → stop, + * @property {object} follow start(handle), refocus(handle), * release(), isFollowing(handle), current() (the tracked handle, or what * another layer follows), onChanged(listener) → remove. * @property {object} trails create({ color, width }) → Trail, diff --git a/src/layers/aircraft/render/display.js b/src/layers/aircraft/render/display.js index 38312657b..bf03bd4da 100644 --- a/src/layers/aircraft/render/display.js +++ b/src/layers/aircraft/render/display.js @@ -22,6 +22,7 @@ import { aircraftIconCells, drawAircraftIconAtlas, } from '../../../data/aircraftIcons.js'; +import { cockpitContactDotImage } from '../../../data/cockpitContactDot.js'; import { createEngineLayerView } from '../../shared/render/engineView.js'; import { Rgba, rgbaFromCss } from '../../shared/color.js'; import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; @@ -114,6 +115,10 @@ export function createAircraftDisplay( const removers = []; const iconCells = aircraftIconCells(); const defaultIcon = [...iconCells.keys()][0]; + const cellFor = (image) => + image && image === cockpitContactDotImage._dataUrl + ? 'cockpit-dot' + : iconCells.get(image); // --- commits: once per frame listener, or in a microtask ----------------- const pending = new Set(); @@ -172,7 +177,8 @@ export function createAircraftDisplay( if (!atlas) return null; const { tracked } = sprite; const depth = depthKey(sprite.disableDepthTestDistance); - const key = `${tracked ? 'tracked' : 'fleet'}|${scalarKey(sprite.scaleByDistance)}|${JSON.stringify(depth)}`; + const dot = sprite.cell === 'cockpit-dot'; + const key = `${tracked ? 'tracked' : 'fleet'}|${scalarKey(sprite.scaleByDistance)}|${JSON.stringify(depth)}|${dot}`; let bank = spriteBanks.get(key); if (!bank) { const scalar = sprite.scaleByDistance; @@ -187,7 +193,9 @@ export function createAircraftDisplay( depthTest: depth, pickable: true, sprite: { - atlas, + atlas: dot + ? { url: sprite.image, cells: { 'cockpit-dot': [0, 0, 1, 1] } } + : atlas, sizePx: BASE_SIZE_PX, scaleByDistance: scalar ? [scalar.near, scalar.nearValue, scalar.far, scalar.farValue] @@ -246,7 +254,7 @@ export function createAircraftDisplay( spec.color?.alpha ?? 1, ); this._image = spec.image; - this.cell = iconCells.get(spec.image) ?? iconCells.get(defaultIcon); + this.cell = cellFor(spec.image) ?? iconCells.get(defaultIcon); this._scaleByDistance = spec.scaleByDistance ? { ...spec.scaleByDistance } : undefined; @@ -369,10 +377,12 @@ export function createAircraftDisplay( set image(value) { this._image = value; - const cell = iconCells.get(value); + const cell = cellFor(value); if (!cell || cell === this.cell) return; + const rebank = (cell === 'cockpit-dot') !== (this.cell === 'cockpit-dot'); this.cell = cell; - this.write({ cell }); + if (rebank) placeSprite(this); + else this.write({ cell }); } get scaleByDistance() { @@ -515,6 +525,11 @@ export function createAircraftDisplay( return this.placed && Boolean(this.gevBank?.set?.ready(this.gevIndex)); } + /** The engine could not load what this instance draws. */ + get failed() { + return Boolean(this.gevBank?.set?.failed(this.gevIndex)); + } + /** The scale drawn: minimumPixelSize kept on the CPU. */ get computedScale() { const minimum = this.spec.minimumPixelSize ?? 0; @@ -581,6 +596,15 @@ export function createAircraftDisplay( } } + // A set does not load a failed glTF again: the tracked model's retry + // loads it in a new one. + const dropModel = (model) => { + const bank = model?.gevBank; + const retry = model?.tracked && model.failed; + bank?.remove(model); + if (retry) bank.rebuild(); + }; + const models = { live: () => attached && !destroyed, setVisible(visible) { @@ -588,18 +612,13 @@ export function createAircraftDisplay( for (const bank of modelBanks.values()) bank.setVisible(visible); }, loadFleet: (spec) => Promise.resolve(new ModelHandle(spec)), - loadTracked: (spec) => Promise.resolve(new ModelHandle(spec)), - // The engine loads each glTF itself; nothing to warm. - preload: (url) => Promise.resolve({ url, destroy() {} }), + loadTracked: (spec) => + Promise.resolve(Object.assign(new ModelHandle(spec), { tracked: true })), add(model) { modelBankFor(model).add(model); }, - remove(model) { - model?.gevBank?.remove(model); - }, - destroy(model) { - model?.gevBank?.remove(model); - }, + remove: dropModel, + destroy: dropModel, /** The heading-pitch-roll frame (modelMatrix, the model frame); the * instance turned to the glTF's own axes (GLTF_AXIS). `offsetDeg`: this * model's nose offset when the layer's models differ (default: the @@ -753,12 +772,9 @@ export function createAircraftDisplay( } handle.gevFollowHandle = followHandle; FOLLOWED_TRACKED.set(followHandle, handle); - // The engine's follow frames itself (Cesium's one exact tracked frame - // included) and ends with it: nothing for the caller to stop. - return () => {}; }, refocus(handle) { - return follow.start(handle); + follow.start(handle); }, release() { engine.camera.following()?.stop(); diff --git a/src/layers/aircraft/render/display.test.mjs b/src/layers/aircraft/render/display.test.mjs index d91470a7a..b57492d7e 100644 --- a/src/layers/aircraft/render/display.test.mjs +++ b/src/layers/aircraft/render/display.test.mjs @@ -316,8 +316,7 @@ for (const makeHarness of HARNESSES) { assert.equal(read().x, state.position.x, 'the position, per frame'); assert.equal(read().alpha, 0, 'the colour, per frame'); - const stop = display.follow.start(handle); - assert.equal(typeof stop, 'function'); + display.follow.start(handle); const following = engine.camera.following(); assert.equal(following, handle.gevFollowHandle); assert.equal(following.native, handle, 'cockpit follows the glyph'); diff --git a/src/layers/aircraft/sceneBinding.js b/src/layers/aircraft/sceneBinding.js index 2323ff6ad..07e967a41 100644 --- a/src/layers/aircraft/sceneBinding.js +++ b/src/layers/aircraft/sceneBinding.js @@ -5,7 +5,6 @@ export function createAircraftSceneBinding({ flightState, services, parts, - resolveModel, enable, }) { let resumeFollow = false; @@ -39,25 +38,6 @@ export function createAircraftSceneBinding({ flightState._renderer = renderer; try { renderer.fleet.attach(); - // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed - // (keeps the retained instance referenced; never rendered). Captured against this epoch so a - // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. - if (!flightState._preloadModel) { - const epoch = flightState._modelEpoch; - renderer.models - .preload(resolveModel()) - .then((m) => { - if (epoch === flightState._modelEpoch) { - flightState._preloadModel = m; - flightState._planeModelLoaded = true; - } else { - renderer.models.destroy(m); - } - }) - .catch(() => { - /* tracked plane just stays a billboard a beat longer */ - }); - } parts.snapshots.presentRecords(); if (flightState._trackedIcao) { parts.tracking._trackFlight(flightState._trackedIcao, { @@ -87,8 +67,6 @@ export function createAircraftSceneBinding({ id, flightState._renderer?.engine, ); - flightState._trackedCameraFrameStop?.(); - flightState._trackedCameraFrameStop = null; if (flightState._trackedEntity) { resumeOrigin = flightState._trackedEntity.gevSelectionOrigin ?? 'programmatic'; @@ -102,14 +80,9 @@ export function createAircraftSceneBinding({ parts.rendering._releaseModels(); // Removing the model collection destroys it and its models. renderer.fleet.detach(viewer); - flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) + flightState._modelEpoch += 1; // Ignore loads from the detached scene. flightState._modelPending.clear(); flightState._modelGen.clear(); - if (flightState._preloadModel) { - renderer.models.destroy(flightState._preloadModel); - flightState._preloadModel = null; - } - flightState._planeModelLoaded = false; flightState._billboards.clear(); flightState._cullPositions.clear(); flightState._detectionObjects.clear(); diff --git a/src/layers/aircraft/sceneBinding.test.mjs b/src/layers/aircraft/sceneBinding.test.mjs index 9fcca7dd3..0745ad75c 100644 --- a/src/layers/aircraft/sceneBinding.test.mjs +++ b/src/layers/aircraft/sceneBinding.test.mjs @@ -81,17 +81,14 @@ function binding(flightState, tracked) { test('resuming an aircraft on Cesium preserves the pending share navigation', (t) => { const shell = observedShell(t); let restored; - binding( - { initialized: true, _trackedIcao: 'a00000', _preloadModel: {} }, - (id, options) => { - restored = { id, ...options }; - follow( - shell.followChanged, - { gevSelectionOrigin: options.origin }, - options.resume, - ); - }, - ).attachRenderer({}); + binding({ initialized: true, _trackedIcao: 'a00000' }, (id, options) => { + restored = { id, ...options }; + follow( + shell.followChanged, + { gevSelectionOrigin: options.origin }, + options.resume, + ); + }).attachRenderer({}); assert.equal(restored.id, 'a00000'); assert.equal(restored.resume, true); assert.equal( @@ -126,7 +123,6 @@ test('an engine switch resumes the aircraft with the origin it was selected by', const flightState = { initialized: true, _trackedIcao: 'a00000', - _preloadModel: null, _modelEpoch: 0, _modelPending: new Map(), _modelGen: new Map(), @@ -136,12 +132,10 @@ test('an engine switch resumes the aircraft with the origin it was selected by', _groundSnap: new Map(), }; const scene = binding(flightState, (id, options) => calls.push(options)); - flightState._preloadModel = {}; scene.attachRenderer({}); assert.equal(calls[0].origin, 'programmatic'); flightState._trackedEntity = { gevSelectionOrigin: 'voice' }; scene.detachRenderer({}); - flightState._preloadModel = {}; scene.attachRenderer({}); assert.equal(calls[1].origin, 'voice'); assert.equal(calls[1].resume, true); diff --git a/src/layers/aircraft/snapshotRestore.test.mjs b/src/layers/aircraft/snapshotRestore.test.mjs new file mode 100644 index 000000000..a42cd2c16 --- /dev/null +++ b/src/layers/aircraft/snapshotRestore.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../geo/index.js'; +import { createFlightSnapshotRenderer } from '../flights/snapshotRenderer.js'; +import { createMilitarySnapshotRenderer } from '../military/snapshotRenderer.js'; + +for (const create of [ + createFlightSnapshotRenderer, + createMilitarySnapshotRenderer, +]) { + test(`${create.name} restores ground contact horizon positions`, () => { + const records = { + data: new Map([ + [ + 'abc123', + { rawLon: 0, rawLat: 0, renderAltitudeM: -30, klass: 'airliner' }, + ], + ]), + }; + const flightState = { + records, + _billboards: new Map(), + _cullPositions: new Map(), + _renderer: { fleet: { add: (spec) => spec } }, + }; + const rendering = new Proxy({}, { get: () => () => ({}) }); + const snapshots = create({ + flightState, + records, + groundFloor: {}, + meshFloor: {}, + militaryRegistry: {}, + rendering, + tracking: {}, + motion: { _deadReckon: () => null }, + queries: {}, + }); + snapshots.presentRecords(); + const position = flightState._cullPositions.get('abc123'); + assert.ok(position); + assert.ok( + Math.abs(geo.cartographic.fromCartesian(position).height - 12) < 0.01, + ); + }); +} diff --git a/src/layers/aircraft/trackedModelFailure.test.mjs b/src/layers/aircraft/trackedModelFailure.test.mjs new file mode 100644 index 000000000..e1c1ffd7a --- /dev/null +++ b/src/layers/aircraft/trackedModelFailure.test.mjs @@ -0,0 +1,183 @@ +// A tracked aircraft's glTF that the engine fails to load: the glyph stays +// the visual, the load is tried again after the backoff in a new mover set +// (a set does not load a failed glTF again), and a contact gives up after its +// failure budget. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { + aircraftAtlasLayout, + createAircraftDisplay, +} from './render/display.js'; +import flightsLayer, { + _setTrackedFlightRefreshStateForTest, + _updateTrackedModelForTest as flightsDriveTrackedModel, + _trackedBillboardColorForTest as flightsTrackedBillboardColor, +} from '../../data/flights.js'; +import militaryFlightsLayer, { + _setTrackedMilitaryRefreshStateForTest, + _updateTrackedModelForTest as militaryDriveTrackedModel, + _trackedBillboardColorForTest as militaryTrackedBillboardColor, +} from '../../data/militaryFlights.js'; +import { clearTr3bRegistry } from '../../data/tr3bRegistry.js'; +import { recordingAircraftRenderer } from '../../testSupport/aircraftRecordingRenderer.mjs'; + +globalThis.window ??= { + dispatchEvent: () => true, + addEventListener() {}, + removeEventListener() {}, +}; + +const layout = aircraftAtlasLayout(); +const drawAtlas = async () => ({ + canvas: { width: layout.width, height: layout.height }, + cells: layout.cells, +}); + +test('a failed tracked model loads again in a new set once it is removed', async (t) => { + const engine = createFakeEngine(); + t.after(() => engine.destroy()); + const display = createAircraftDisplay(engine, { + layerId: 'flights', + headingOffsetDeg: 180, + drawAtlas, + }); + t.after(() => display.destroy()); + await display.whenReady(); + display.fleet.attach(); + const failing = new Set(); + engine.fake.script('moverFailed', (id) => failing.has(id)); + const spec = { url: 'plane.glb', minimumPixelSize: 64, scale: 1, id: 'abc' }; + + const first = await display.models.loadTracked(spec); + display.models.add(first); + const failedSet = first.gevBank.set.id; + assert.equal(first.failed, false); + failing.add(failedSet); + assert.equal(first.failed, true, 'the engine reports the failed load'); + display.models.remove(first); + + const retry = await display.models.loadTracked(spec); + display.models.add(retry); + assert.notEqual(retry.gevBank.set.id, failedSet, 'a new set loads it again'); + assert.equal(retry.failed, false); +}); + +/** A viewer stub inside the tracked model regime. */ +function viewerAt(height) { + const viewer = { + camera: { + positionCartographic: { height }, + positionWC: Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 5000), + frustum: { fovy: Cesium.Math.toRadians(60) }, + }, + scene: { + canvas: { clientHeight: 900 }, + frameState: { frameNumber: 1 }, + primitives: { length: 0, get() {} }, + sampleHeight: () => undefined, + }, + trackedEntity: undefined, + entities: { remove() {} }, + }; + viewer.engine = { + isDestroyed: () => false, + surface: { tilesLoaded: () => true, sampleHeightDetailed: () => undefined }, + }; + return viewer; +} + +const LAYERS = [ + { + name: 'flights', + layer: flightsLayer, + seed: _setTrackedFlightRefreshStateForTest, + drive: flightsDriveTrackedModel, + billboardColor: flightsTrackedBillboardColor, + meta: { callsign: 'TEST123 ', altitude: 10_668, klass: 'airliner' }, + }, + { + name: 'military', + layer: militaryFlightsLayer, + seed: _setTrackedMilitaryRefreshStateForTest, + drive: militaryDriveTrackedModel, + billboardColor: militaryTrackedBillboardColor, + meta: { callsign: 'TEST123 ', altitudeFt: 35_000, klass: 'fastjet' }, + }, +]; + +for (const fixture of LAYERS) { + test(`${fixture.name}: a tracked model the engine fails to load is retried, then given up`, async () => { + clearTr3bRegistry(); + fixture.layer.setParams({ models3d: false }); + const realNow = Date.now; + const realWarn = console.warn; + let now = 1_000_000; + Date.now = () => now; + const warnings = []; + console.warn = (...args) => warnings.push(args.join(' ')); + let loads = 0; + const removed = []; + const viewer = viewerAt(30_000); + try { + const billboard = { + position: Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668), + color: null, + show: true, + rotation: 0, + }; + fixture.seed({ + renderer: () => + recordingAircraftRenderer(viewer.engine, { + view: { camera: viewer.camera, scene: viewer.scene }, + fleet: { shown: true }, + models: { + live: () => true, + loadTracked: async (spec) => { + loads++; + return { + spec, + failed: true, + ready: false, + show: false, + modelMatrix: geo.matrix4.create(), + }; + }, + add() {}, + remove: (model) => removed.push(model), + destroy() {}, + setPose: () => geo.matrix4.create(), + }, + }), + icao24: 'abc123', + entity: null, + billboard, + billboardCollection: { show: true, remove() {} }, + viewer, + tracked: true, + meta: { ...fixture.meta, onGround: false }, + }); + for (let frame = 0; frame < 30; frame++) { + fixture.drive(); + await Promise.resolve(); + await Promise.resolve(); + now += 2000; // past the retry backoff + } + assert.ok(loads > 1, `the failed load is tried again (${loads})`); + assert.ok(loads <= 3, `within the failure budget (${loads})`); + assert.equal(removed.length, loads, 'each failed model leaves the scene'); + assert.equal(warnings.length, 1, 'the give-up is announced once'); + assert.equal( + fixture.billboardColor().alpha, + 1, + 'the tracked glyph stays the visual', + ); + } finally { + Date.now = realNow; + console.warn = realWarn; + fixture.layer.stopTracking(); + } + }); +} diff --git a/src/layers/aircraft/tracking.js b/src/layers/aircraft/tracking.js index b1d525621..082fd70aa 100644 --- a/src/layers/aircraft/tracking.js +++ b/src/layers/aircraft/tracking.js @@ -779,8 +779,6 @@ export function createAircraftTracking({ skipViewerUntrack = false, { evicted = false, origin = 'programmatic' } = {}, ) { - flightState._trackedCameraFrameStop?.(); - flightState._trackedCameraFrameStop = null; if (!flightState._trackedIcao) { clearFocusTarget(focusId); return; @@ -910,16 +908,7 @@ export function createAircraftTracking({ // DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always // resolves into an aircraft. // - // The billboard stays SHOWN at all times and is hidden by going TRANSPARENT (alpha 0), not by - // show=false. This is deliberate: viewer.trackedEntity derives the follow-camera framing from - // the entity's bounding sphere, and a 3D model graphic reports a PENDING sphere until its glTF - // finishes loading. If we hid the billboard outright, tracking a plane while already zoomed in - // would stall the centering until the model loaded. A shown-but-transparent billboard always - // supplies a ready sphere, so framing is instant; we only drop its alpha once the model GLB is - // preloaded (_planeModelLoaded), so there's neither a billboard+model double-image nor a gap. - // The tracked entity is a PURE BILLBOARD — no label or model graphic. The 3D model for the - // tracked plane is a standalone primitive driven by _updateTrackedModel(); keeping it off the - // entity is what makes the follow-camera's bounding sphere always ready (see _trackedModel). + // The tracked sprite becomes transparent only while the standalone model is ready and shown. flightState._trackedEntity = flightState._renderer.tracked.create({ // Dead-reckoning position — smooth continuous motion between API updates. position: getTrackedPosition, @@ -1014,8 +1003,7 @@ export function createAircraftTracking({ const restoring = resume || origin === 'share-restore'; if (restoring) entity.gevRestoring = true; try { - flightState._trackedCameraFrameStop = - flightState._renderer.follow.start(entity); + flightState._renderer.follow.start(entity); } finally { if (restoring) delete entity.gevRestoring; } diff --git a/src/layers/alpr/overlay.js b/src/layers/alpr/overlay.js index 8f1164133..81f90196e 100644 --- a/src/layers/alpr/overlay.js +++ b/src/layers/alpr/overlay.js @@ -19,6 +19,8 @@ import { validAlprGroundHeight, } from './visuals.js'; +let overlaySequence = 0; + /** * Canvas artwork for the nearest ALPR cameras over each engine's native * markers, with bounded surface refinement. The engine adapter owns marker @@ -341,7 +343,7 @@ export function createAlprOverlayCore({ if (!services.overlays?.registerPaintLane || typeof Image === 'undefined') return; lane = services.overlays.registerPaintLane('selected', paint, { - id: `alpr-${globalThis.crypto.randomUUID()}`, + id: `alpr-${++overlaySequence}`, active: false, }); images = [MARKER_IMAGE, SELECTED_IMAGE, BRACKETS_IMAGE].map((src) => { diff --git a/src/layers/alpr/overlayInsecureContext.test.mjs b/src/layers/alpr/overlayInsecureContext.test.mjs new file mode 100644 index 000000000..c9c8cdadb --- /dev/null +++ b/src/layers/alpr/overlayInsecureContext.test.mjs @@ -0,0 +1,45 @@ +// The ALPR paint lane must initialize in an insecure context (plain HTTP over +// the HOST=0.0.0.0 LAN opt-in), where crypto.randomUUID does not exist. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createAlprOverlayCore } from './overlay.js'; + +test('ALPR overlay init works without crypto.randomUUID', (t) => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); + const insecure = { + getRandomValues: (a) => globalThis.crypto.getRandomValues(a), + }; + const hadImage = 'Image' in globalThis; + if (!hadImage) + globalThis.Image = class { + set src(_) {} + }; + Object.defineProperty(globalThis, 'crypto', { + value: insecure, + configurable: true, + }); + t.after(() => { + Object.defineProperty(globalThis, 'crypto', descriptor); + if (!hadImage) delete globalThis.Image; + }); + let lane = null; + const overlay = createAlprOverlayCore({ + state: { enabled: true }, + services: { + overlays: { + subscribeMapStack: () => () => {}, + registerPaintLane: (_name, _paint, options) => { + lane = { options, requestPaint() {}, remove() {}, destroy() {} }; + return lane; + }, + }, + }, + engine: {}, + markers: new Map(), + syncMarker() {}, + sampleHeight() {}, + terrainHeight() {}, + }); + assert.doesNotThrow(() => overlay.init()); + assert.match(lane?.options.id ?? '', /^alpr-/); +}); diff --git a/src/layers/cctv/attachDisabled.test.mjs b/src/layers/cctv/attachDisabled.test.mjs new file mode 100644 index 000000000..b0b004972 --- /dev/null +++ b/src/layers/cctv/attachDisabled.test.mjs @@ -0,0 +1,64 @@ +// A CCTV layer that was initialized and then switched off must stay hidden +// across an engine switch: detachRenderer() + attachRenderer() on the new +// engine while _enabled is false must not draw the camera markers. The active +// camera is left for an obstruction probe on the new engine's surface. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../geo/index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { loadLayerRenderers } from '../../app/layers/renderers.js'; +import cctvLayer, { _setCctvCoverageStateForTest } from '../../data/cctv.js'; + +await loadLayerRenderers('cesium'); + +test('disabled CCTV markers stay hidden after an engine switch', () => { + const first = createFakeEngine({ + width: 1280, + height: 800, + altitude: 50_000, + }); + const second = createFakeEngine({ + width: 1280, + height: 800, + altitude: 50_000, + }); + const position = geo.cartesian3.fromDegrees(0, 0, 10); + const record = { + camera: { id: 'cam-1', lat: 0, lon: 0, feedType: 'image' }, + position, + coverageEntities: [], + activationDone: true, + }; + _setCctvCoverageStateForTest({ + engine: first, + records: [record], + activeCameraId: 'cam-1', + enabled: false, + }); + try { + cctvLayer.detachRenderer(); + cctvLayer.attachRenderer(null, { engine: second }); + second.fake.frames(2); + const set = second.fake.moverSet('cctv'); + assert.ok(set, 'marker set exists'); + assert.equal( + record.activationDone, + false, + 'the new surface needs an activation probe', + ); + assert.equal(record.surfaceProbePending, true); + assert.equal(set.store.count ?? set.count ?? 1, 1); + assert.equal( + set.store.shows[0], + 0, + 'marker of the disabled layer is hidden', + ); + } finally { + try { + cctvLayer.detachRenderer(); + } catch {} + _setCctvCoverageStateForTest({ enabled: false }); + first.destroy(); + second.destroy(); + } +}); diff --git a/src/layers/cctv/ingestion.js b/src/layers/cctv/ingestion.js index f80469769..c81da24f5 100644 --- a/src/layers/cctv/ingestion.js +++ b/src/layers/cctv/ingestion.js @@ -29,6 +29,17 @@ export function createIngestion({ parts.model.projectionTilesReady() ) { layerState._tilesReadyReenqueued = true; + const active = parts.selection.getActiveRecord(); + if (active?.surfaceProbePending) { + active.surfaceProbePending = false; + parts.geometry.runActivationObstructionProbe(active); + parts.geometry.applyFrustumGeometry( + active, + parts.ground.groundAltFor(active), + ); + active.activationDone = true; + changed = true; + } // Per-regime resolution (Task 5): only records unresolved for the // CURRENT surface regime need the completion pass. On globe stacks // projectionTilesReady() is false while a (hidden) Google tileset diff --git a/src/layers/cctv/ingestion.test.mjs b/src/layers/cctv/ingestion.test.mjs index 0fc0d72d7..e60ca9719 100644 --- a/src/layers/cctv/ingestion.test.mjs +++ b/src/layers/cctv/ingestion.test.mjs @@ -26,6 +26,7 @@ function setup() { }, }, navigation: { maybeAutoHop() {} }, + selection: { getActiveRecord: () => record }, presentation: { notifyListeners() { calls.notify++; @@ -100,3 +101,25 @@ test('a tick resuming after an engine switch released the renderer does no scene assert.equal(sceneWork, 0); assert.equal(h.calls.notify, 1); }); + +test('an engine switch probes the active camera once the new surface is ready', async () => { + const h = setup(); + const probes = []; + h.parts.geometry = { + runActivationObstructionProbe: (record) => probes.push(['probe', record]), + applyFrustumGeometry: (record, ground) => + probes.push(['geometry', record, ground]), + }; + h.parts.ground.groundAltFor = () => 12; + h.record.surfaceProbePending = true; + h.state._tilesReadyReenqueued = false; + assert.equal((await h.layer.update()).changed, true); + assert.deepEqual(probes, [ + ['probe', h.record], + ['geometry', h.record, 12], + ]); + assert.equal(h.record.activationDone, true, 're-selecting it is a no-op'); + h.state._tilesReadyReenqueued = false; + await h.layer.update(); + assert.equal(probes.length, 2, 'one probe per engine switch'); +}); diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js index 9271e385f..4fc49467f 100644 --- a/src/layers/cctv/lifecycle.js +++ b/src/layers/cctv/lifecycle.js @@ -45,11 +45,11 @@ export function createLifecycle({ /** Records the camera pose the last view pass ran at. */ function noteViewPose() { - const camera = layerState._viewer?.camera; - if (!camera?.positionWC) return; + const camera = parts.renderer?.cameraPose(); + if (!camera?.position) return; layerState._viewPose = { - position: geo.cartesian3.clone(camera.positionWC), - direction: camera.directionWC && geo.cartesian3.clone(camera.directionWC), + position: geo.cartesian3.clone(camera.position), + direction: camera.direction && geo.cartesian3.clone(camera.direction), }; } @@ -59,16 +59,14 @@ export function createLifecycle({ * would otherwise rerun the full pass on every tick. */ function viewPoseChanged() { - const camera = layerState._viewer?.camera; + const camera = parts.renderer?.cameraPose(); const pose = layerState._viewPose; - if (!pose || !camera?.positionWC) return true; - const height = Math.max(1, camera.positionCartographic?.height ?? 0); - const moved = geo.cartesian3.distance(camera.positionWC, pose.position); + if (!pose || !camera?.position) return true; + const height = Math.max(1, camera.cartographic?.height ?? 0); + const moved = geo.cartesian3.distance(camera.position, pose.position); if (moved > height * VIEW_MOTION_MIN_MOVE_FRACTION) return true; - if (!pose.direction || !camera.directionWC) return false; - return ( - geo.cartesian3.dot(camera.directionWC, pose.direction) < MIN_TURN_COS - ); + if (!pose.direction || !camera.direction) return false; + return geo.cartesian3.dot(camera.direction, pose.direction) < MIN_TURN_COS; } function viewMotionTick() { @@ -428,6 +426,7 @@ export function createLifecycle({ const renderer = createRenderer(viewer, options); layerState._restoringViewport = Boolean(options.waitForCameraRestore); renderer.createMarkers(); + const active = parts.selection.getActiveRecord(); for (const record of layerState._records) { record.billboard = renderer.createMarker( record.camera, @@ -436,7 +435,13 @@ export function createLifecycle({ record.groundResolved = {}; record.groundSamples = {}; record.probeClampRangeM = null; + // The new surface is unprobed: update() probes the active camera + // once its tiles are in. + record.surfaceProbePending = + record === active && record.activationDone === true; + record.activationDone = false; } + renderer.setMarkersVisible(layerState._enabled); bindScene(viewer, options); if (!layerState._restoringViewport && layerState._enabled) this.resumeDisplay(); diff --git a/src/layers/cctv/render/display.js b/src/layers/cctv/render/display.js index 083462942..0839b8fd3 100644 --- a/src/layers/cctv/render/display.js +++ b/src/layers/cctv/render/display.js @@ -569,6 +569,7 @@ export function createCctvDisplay(engine, { state, services = {} } = {}) { get ready() { return initialized && !destroyed && !engine.isDestroyed(); }, + cameraPose: () => engine.camera.pose(), cameraCartographic: () => engine.camera.pose().cartographic, following: () => Boolean(engine.camera.following()), globeVisible: () => !engine.maps.photorealActive(), diff --git a/src/layers/cctv/rendering.js b/src/layers/cctv/rendering.js index 4fabd559f..3c1e050ce 100644 --- a/src/layers/cctv/rendering.js +++ b/src/layers/cctv/rendering.js @@ -177,7 +177,7 @@ export function createRendering({ const minY = -height * VIEW_ICON_MARGIN; const maxY = height * (1 + VIEW_ICON_MARGIN); // Ground metres one pixel covers per metre of distance from the viewer. - const fovy = layerState._viewer?.camera?.frustum?.fovy || Math.PI / 3; + const fovy = parts.renderer.cameraPose()?.fovy || Math.PI / 3; const metresPerPixelPerMetre = (2 * Math.tan(fovy / 2)) / Math.max(1, height); const activeId = layerState._activeCameraId; diff --git a/src/layers/cctv/viewFovy.test.mjs b/src/layers/cctv/viewFovy.test.mjs new file mode 100644 index 000000000..5c46a4898 --- /dev/null +++ b/src/layers/cctv/viewFovy.test.mjs @@ -0,0 +1,57 @@ +// CCTV icon thinning must use the camera's real vertical FOV. In a 16:9 +// window with Cesium's default fov 60deg the vertical FOV is about 36deg; +// main read it from viewer.camera.frustum.fovy. Two cameras 17 m apart seen +// from 2 km: with fovy 36deg the cell is ~48 m (< VIEW_MIN_CELL_M, both shown), +// with the hard-coded pi/3 fallback it is ~86 m and one camera is hidden. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRendering } from './rendering.js'; +import { isVideoFeedType } from '../../sources/cctvTypes.js'; + +const FOVY_16_9 = 2 * Math.atan(Math.tan(Math.PI / 6) / (16 / 9)); + +test('icon thinning uses the camera vertical FOV, not a pi/3 fallback', () => { + const record = (id, lat, x) => ({ + camera: { id, lat, lon: 0, feedType: 'image' }, + billboard: { show: true, position: { x, y: 0, z: 0 } }, + }); + const a = record('cam-a', 10.0005, 0); + const b = record('cam-b', 10.00065, 17); + const pose = { fovy: FOVY_16_9 }; + const state = { + _enabled: true, + _records: [a, b], + _viewRecords: [], + _healthById: new Map(), + _activeCameraId: null, + }; + const renderer = { + ready: true, + viewport: () => ({ width: 1920, height: 1080 }), + horizonTest: () => () => true, + project: () => ({ x: 960, y: 540 }), + distance: () => 2000, + // Accessor the fix is expected to add (any name works if the stub follows). + cameraPose: () => pose, + fovy: () => pose.fovy, + engine: { camera: { pose: () => pose } }, + }; + const { refreshHorizonCulling } = createRendering({ + state, + services: { + focus: { + focusNowMs: (n) => n, + getFocusTarget: () => null, + focusPassIsNeeded: () => false, + advanceSpriteFocus: () => ({ factor: 1 }), + focusAlphaNeedsWrite: () => false, + }, + }, + parts: { renderer, model: { isVideoFeedType } }, + source: {}, + }); + refreshHorizonCulling(); + assert.equal(a.billboard.show, true, 'cam-a shown'); + assert.equal(b.billboard.show, true, 'cam-b shown'); + assert.equal(state._viewRecords.length, 2); +}); diff --git a/src/layers/directions/render/display.js b/src/layers/directions/render/display.js index 27f593578..d65ab1c44 100644 --- a/src/layers/directions/render/display.js +++ b/src/layers/directions/render/display.js @@ -206,6 +206,12 @@ export function createDirectionsDisplay(engine) { const removeMoveEnd = engine.camera.moveEnd.addEventListener(() => { if (ends.a || ends.b) placeMarkers(); }); + let tilesLoaded = engine.surface.tilesLoaded(); + const removeSurface = engine.frames.preRender.addEventListener(() => { + const loaded = engine.surface.tilesLoaded(); + if (loaded && !tilesLoaded && visible && (ends.a || ends.b)) placeMarkers(); + tilesLoaded = loaded; + }); placeMarkers(); return { @@ -276,6 +282,7 @@ export function createDirectionsDisplay(engine) { if (destroyed) return; destroyed = true; removeMoveEnd(); + removeSurface(); route?.destroy(); steps?.destroy(); markers.destroy(); diff --git a/src/layers/directions/render/display.test.mjs b/src/layers/directions/render/display.test.mjs index efd2aefda..146031abb 100644 --- a/src/layers/directions/render/display.test.mjs +++ b/src/layers/directions/render/display.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as geo from '../../../geo/index.js'; +import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs'; import { createApplicationDirectionsLayer } from '../../../data/directions.js'; import { @@ -324,3 +325,29 @@ test('an engine switch redraws the route, dots and markers on the new engine onl assert.equal(movers(IDS.markers).rows[1].show, true); f.layer.detachRenderer(); }); + +test('route endpoints refine when the surface finishes loading', (t) => { + const engine = createFakeEngine(); + t.after(() => engine.destroy()); + let height; + let loaded = false; + engine.fake.script('sampleHeight', () => height); + engine.fake.script('tilesLoaded', () => loaded); + const display = createDirectionsDisplay(engine); + t.after(() => display.destroy()); + display.setMarker('a', { lon: 0, lat: 0 }); + engine.fake.frames(1); + height = 340; + loaded = true; + engine.fake.frames(1); + const { positions } = engine.fake.moverSet('directions-markers').store; + assert.ok( + Math.abs( + geo.cartographic.fromCartesian({ + x: positions[0], + y: positions[1], + z: positions[2], + }).height - 340, + ) < 0.01, + ); +}); diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index bc60a3e4b..e51f3139c 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -1,13 +1,6 @@ import { createAircraftSceneBinding } from '../aircraft/sceneBinding.js'; -import { PLANE_MODEL_URL } from './policy.js'; -export function createLifecycle({ - flightState, - services, - parts, - layer, - resolveAsset, -}) { +export function createLifecycle({ flightState, services, parts, layer }) { const { clearFocusTarget } = services.focus; const { onMilitaryLayerActiveChange } = services.militaryRegistry; const { holdContinuousRender, releaseContinuousRender } = services.render; @@ -19,7 +12,6 @@ export function createLifecycle({ flightState, services, parts, - resolveModel: () => resolveAsset(PLANE_MODEL_URL), enable: () => methods.enable(), }); diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index a1dbf5d8a..a3a821ba2 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -764,9 +764,7 @@ export function createQueries({ // follow): asking to track it again follows it again. const entity = flightState._trackedEntity; if (entity && !flightState._renderer?.follow.isFollowing(entity)) { - flightState._trackedCameraFrameStop?.(); - flightState._trackedCameraFrameStop = - flightState._renderer.follow.refocus(entity); + flightState._renderer.follow.refocus(entity); } return parts.tracking._publishTrackedSelection(id, origin); } @@ -842,9 +840,7 @@ export function createQueries({ !flightState._renderer.follow.isFollowing(flightState._trackedEntity) ) return false; - flightState._trackedCameraFrameStop?.(); - flightState._trackedCameraFrameStop = - flightState._renderer.follow.refocus(flightState._trackedEntity); + flightState._renderer.follow.refocus(flightState._trackedEntity); parts.tracking._publishTrackedSelection(id, origin); return true; }, diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 9e2872778..fb2dc7c51 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -555,7 +555,6 @@ export function createRendering({ model.show = false; flightState._renderer.models.add(model); flightState._models.set(icao24, model); - flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard } /** Remove the 3D model for ONE aircraft (removal / military-suppression / track handoff). @@ -720,13 +719,7 @@ export function createRendering({ } flightState._trackedModel = m; flightState._trackedModelLoading = false; - // A good load retires this selection's failure budget — a contact that - // recovers after a transient blip is not one attempt from giving up. - flightState._trackedModelFailIcao = null; - flightState._trackedModelFailCount = 0; - flightState._trackedModelRetryAtMs = 0; flightState._renderer.models.add(m); - flightState._planeModelLoaded = true; // GLB cached — the tracked billboard can fade out once the model is up }) .catch((err) => { if (gen !== flightState._trackedModelGen) return; // superseded load — not this selection's failure @@ -735,6 +728,17 @@ export function createRendering({ }); return; // billboard carries the visual until the model is ready } + if (flightState._trackedModel?.failed) { + // The engine could not load the glTF: the billboard stays the visual + // and the load is tried again after the backoff. + const { url } = flightState._trackedModel.spec ?? {}; + _releaseTrackedModel(); + parts.tracking._noteTrackedModelLoadFailure( + url, + new Error('model failed to load'), + ); + return; + } if (flightState._trackedModel) { // Keep the transform current while GPU resources are still loading. Cesium // can flip ready during scene update after this callback; waiting for ready @@ -754,6 +758,13 @@ export function createRendering({ flightState._trackedModel, ); if (!flightState._trackedModel.ready) return; + // A model that loaded retires this selection's failure budget: a contact + // that recovers after a transient blip is not one attempt from giving up. + if (flightState._trackedModelFailIcao === flightState._trackedIcao) { + flightState._trackedModelFailIcao = null; + flightState._trackedModelFailCount = 0; + flightState._trackedModelRetryAtMs = 0; + } const spec = _modelSpec( flightState.records.data.get(flightState._trackedIcao)?.klass, ); diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index e5b011f54..ee86eb069 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -25,6 +25,17 @@ export function createFlightSnapshotRenderer({ const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = militaryRegistry; const { warmGroundFloor } = groundFloor; const { sampleMeshFloorCells } = meshFloor; + // A low contact is horizon-culled from 12 m up: its ground can lie below + // the ellipsoid. + function setCullPosition(id, meta) { + flightState._cullPositions.set( + id, + meta.renderAltitudeM < 10 + ? geo.cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12) + : null, + ); + } + function presentContact(icao24, meta, position, prevMeta, groundFlipped) { const isTracked = icao24 === flightState._trackedIcao; if (flightState._billboards.has(icao24)) { @@ -101,6 +112,7 @@ export function createFlightSnapshotRenderer({ meta.rawLat, meta.renderAltitudeM, ); + setCullPosition(id, meta); presentContact(id, meta, position, null, false); } } @@ -164,12 +176,7 @@ export function createFlightSnapshotRenderer({ ); // Either flip retires the previous one-shot ground sample. if (groundFlipped) flightState._groundSnap.forget(icao24); - flightState._cullPositions.set( - icao24, - meta.renderAltitudeM < 10 - ? geo.cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12) - : null, - ); + setCullPosition(icao24, meta); const isTracked = icao24 === flightState._trackedIcao; // Append to position history stamped with the FEED's fix epoch diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index 1308aff95..cd4cc55f9 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -73,18 +73,6 @@ export function createFlightState({ source, services }) { flightState._lastModelCapWarnMs = 0; // throttle the "more planes in view than the cap" console notice - // The tracked entity's billboard goes transparent (rather than hidden) once the model takes - // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its - // alpha after the GLB is preloaded so the model is ready to render the instant the billboard - // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not - // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. - - flightState._planeModelLoaded = false; - - /** @type {object|null} retained preload that keeps the glTF cache warm */ - - flightState._preloadModel = null; - /** Last limb taper per billboard, retained across class/ground/cockpit repaints. */ flightState._billboardLimbScale = new WeakMap(); @@ -126,10 +114,6 @@ export function createFlightState({ source, services }) { flightState._trackedEntity = null; - /** Disposes the single active tracked-camera framing owner. */ - - flightState._trackedCameraFrameStop = null; - /** @type {object|null} Standalone 3D model for the tracked aircraft. Deliberately NOT a * graphic on _trackedEntity: viewer.trackedEntity derives the follow-camera from the entity's * bounding sphere, and a model graphic reports PENDING until its glTF loads — which stalls (or, on diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js index 9f4bae5ee..b815a1607 100644 --- a/src/layers/flights/testing.js +++ b/src/layers/flights/testing.js @@ -198,6 +198,20 @@ export function createTesting({ await parts.rendering._ensureModel(icao24); return flightState._models.get(icao24) || null; } + + /** The renderer handles one contact draws with, for QA probes that tamper + * with them: its glyph and fleet model, and the tracked glyph and model + * while it is tracked. */ + + function _aircraftHandlesForTest(icao24) { + const tracked = icao24 === flightState._trackedIcao; + return { + sprite: flightState._billboards.get(icao24) ?? null, + model: flightState._models.get(icao24) ?? null, + trackedSprite: tracked ? flightState._trackedEntity : null, + trackedModel: tracked ? flightState._trackedModel : null, + }; + } return { _floorGroundedDisplayPositionForTest, _clearDisplayFloorStateForTest, @@ -214,5 +228,6 @@ export function createTesting({ _trackedBillboardColorForTest, _driveFleetModelHandoffForTest, _ensureFleetModelForTest, + _aircraftHandlesForTest, }; } diff --git a/src/layers/ghostMaps/edgeCases.test.mjs b/src/layers/ghostMaps/edgeCases.test.mjs index 2aec18bb9..d12f73a31 100644 --- a/src/layers/ghostMaps/edgeCases.test.mjs +++ b/src/layers/ghostMaps/edgeCases.test.mjs @@ -86,6 +86,11 @@ function fakeRenderer(name, live) { id, names: [...features.values()].map((feature) => feature.name), ready: Promise.resolve(), + drop(index) { + paint.names = [...features] + .filter(([key]) => key !== index) + .map(([, feature]) => feature.name); + }, destroy() { live.delete(paint); }, diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index 1aa98f9a7..491271c20 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -209,21 +209,18 @@ export function createGhostMapsLayer({ } // Remove expired events, rather than hiding them: horizon culling must - // never make an old pin visible again. A drawing that cannot drop one - // report is redrawn. Map keys keep surviving picks stable. + // never make an old pin visible again. Map keys keep surviving picks stable. function expireEvents() { const at = now(); let changed = false; for (const [productId, item] of _loaded) { - let redraw = false; for (const [index, feature] of item.features) { if (!isGhostMapsEventExpired(feature.dateMs, at, _ageDays)) continue; item.features.delete(index); - if (item.paint && !item.paint.drop?.(index)) redraw = true; + item.paint?.drop(index); if (_selectedKey === `${productId}:${index}`) clearSelection(); changed = true; } - if (redraw) repaint(productId, item); } if (changed) { notifyRow(); @@ -231,22 +228,6 @@ export function createGhostMapsLayer({ } } - // The new drawing is on the globe before the old one leaves. - function repaint(id, item) { - _queue = _queue.then(async () => { - if (_loaded.get(id) !== item) return; - const old = item.paint; - item.paint = null; - try { - await paint(id, item, _lifetime.signal); - } catch (error) { - console.warn(`[Data:GhostMaps] ${id}:`, error); - } finally { - if (item.paint !== old) old?.destroy(); - } - }); - } - /** Decode one product without a scene. */ async function load(product, signal) { const ageDays = _ageDays; @@ -302,19 +283,25 @@ export function createGhostMapsLayer({ PRODUCT_COLORS[id] || ACCENT, markerImage, ); + item.paint = next; + const abort = () => next.destroy(); + signal.addEventListener('abort', abort, { once: true }); try { await next.ready; if (owner === _display && !signal.aborted && !item.removed) { - item.paint = next; return; } } catch (error) { next.destroy(); + if (item.paint === next) item.paint = null; if (owner === _display && !signal.aborted && !item.removed) throw error; if (_loaded.get(id) === item) return; continue; + } finally { + signal.removeEventListener('abort', abort); } next.destroy(); + if (item.paint === next) item.paint = null; if (_loaded.get(id) === item) return; } } diff --git a/src/layers/ghostMaps/pendingPaint.test.mjs b/src/layers/ghostMaps/pendingPaint.test.mjs new file mode 100644 index 000000000..be1b6a00e --- /dev/null +++ b/src/layers/ghostMaps/pendingPaint.test.mjs @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGhostMapsLayer } from './index.js'; + +function element(localName, children = [], value = '') { + const node = { + localName, + children, + get textContent() { + return value + children.map((child) => child.textContent).join(''); + }, + getAttribute: () => null, + getElementsByTagName(name) { + return children.flatMap((child) => [ + ...(name === '*' || name === child.localName ? [child] : []), + ...child.getElementsByTagName(name), + ]); + }, + }; + for (const child of children) child.parentElement = node; + return node; +} + +// The XML double turns `NAME` into one placemark called NAME. +function documentFor(xml) { + const name = /(.*)<\/kml>/.exec(xml)?.[1] ?? 'unnamed'; + return element('document', [ + element('kml', [ + element('Document', [ + element('Placemark', [ + element('name', [], name), + element( + 'description', + [], + '
Date2026-01-10
', + ), + element('Point', [element('coordinates', [], '12,34')]), + ]), + ]), + ]), + ]); +} + +/** A stored (uncompressed) KMZ holding doc.kml only. */ +function kmz(name) { + const key = Buffer.from('doc.kml'); + const bytes = Buffer.from(`${name}`); + const header = Buffer.alloc(30); + header.writeUInt32LE(0x04034b50); + header.writeUInt32LE(bytes.length, 18); + header.writeUInt32LE(bytes.length, 22); + header.writeUInt16LE(key.length, 26); + const directory = Buffer.alloc(46); + directory.writeUInt32LE(0x02014b50); + directory.writeUInt32LE(bytes.length, 20); + directory.writeUInt32LE(bytes.length, 24); + directory.writeUInt16LE(key.length, 28); + directory.writeUInt32LE(0, 42); + const central = Buffer.concat([directory, key]); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50); + end.writeUInt16LE(1, 8); + end.writeUInt16LE(1, 10); + end.writeUInt32LE(central.length, 12); + end.writeUInt32LE(header.length + key.length + bytes.length, 16); + return new Uint8Array(Buffer.concat([header, key, bytes, central, end])); +} + +function deferred() { + let resolve; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; +} + +const catalog = (version) => ({ + products: [{ id: 'cip', version, defaultOn: true }], +}); +const names = (layer) => layer.getAnalystRecords().map((record) => record.name); + +const DAY = 86400_000; + +for (const action of ['expire', 'disable']) { + test(`${action} removes a drawing while its icons decode`, async (t) => { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser'); + globalThis.DOMParser = class { + parseFromString(xml) { + return documentFor(xml); + } + }; + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'DOMParser', previous); + else delete globalThis.DOMParser; + }); + t.mock.method(console, 'warn', () => {}); + let clock = Date.UTC(2026, 0, 12); + const live = new Set(); + const gates = []; + const layer = createGhostMapsLayer({ + storage: null, + now: () => clock, + createRenderer: (engine) => ({ + requestRender() {}, + onClick: () => () => {}, + draw(id, features) { + const gate = deferred(); + if (engine.name === 'navara') gates.push(gate); + else gate.resolve(); + const paint = { + engine: engine.name, + names: [...features.values()].map((feature) => feature.name), + dropped: [], + ready: gate.promise, + drop(index) { + paint.dropped.push(index); + paint.names = []; + return true; + }, + destroy() { + live.delete(paint); + }, + }; + live.add(paint); + return paint; + }, + destroy() {}, + }), + source: { + getCatalog: async () => catalog('v1'), + getFile: async () => kmz('old event'), + }, + }); + layer.init(null, { engine: { name: 'cesium' } }); + layer.enable(); + t.after(() => layer.destroy()); + await layer.update(); + assert.deepEqual(names(layer), ['old event']); + + // Engine switch: the new drawing is still decoding its icons. + layer.detachRenderer(); + const attached = layer.attachRenderer(null, { engine: { name: 'navara' } }); + await new Promise(setImmediate); + assert.equal(gates.length, 1); + // The event leaves its age window; the expiry pass runs meanwhile. + if (action === 'expire') { + clock += 30 * DAY; + await layer.update(); + } else { + layer.disable(); + assert.equal( + live.size, + 0, + 'disabled drawings are removed before decoding finishes', + ); + } + assert.deepEqual(names(layer), [], 'the record is expired'); + gates[0].resolve(); + await attached; + for (let i = 0; i < 10; i += 1) await new Promise(setImmediate); + + const drawn = [...live].filter((paint) => paint.names.length); + assert.deepEqual( + drawn.map((paint) => `${paint.engine}:${paint.names.join(',')}`), + [], + 'no drawing keeps the expired event', + ); + }); +} diff --git a/src/layers/ghostMaps/render/display.js b/src/layers/ghostMaps/render/display.js index ef93c924e..6dbe3bbbb 100644 --- a/src/layers/ghostMaps/render/display.js +++ b/src/layers/ghostMaps/render/display.js @@ -8,11 +8,13 @@ // outline adds `:outline`), which the layer resolves to its report. // Placemarks draw over the scene (depth test off) and are hidden beyond the // horizon instead, as they would sink into terrain and 3D tiles otherwise. +// They stand on the sampled surface, measured for the ones on screen. import * as geo from '../../../geo/index.js'; import { normalizeGroundItem } from '../../../globe/ground.js'; import { rgbaFromCss } from '../../shared/color.js'; import { imageAtlasLayout } from '../../shared/render/imageAtlas.js'; +import { createSurfacePoints } from '../../shared/render/surfacePoints.js'; const LAYER_ID = 's2-ghostmaps'; const TRANSPARENT = Object.freeze([0, 0, 0, 0]); @@ -85,7 +87,7 @@ function editionItems(productId, features, color, markerImage) { report: index, id, url, - // On the ellipsoid: movers have no ground clamp. + cartographic: geo.cartographic.fromDegrees(lon, lat), position: geo.cartesian3.fromDegrees(lon, lat), horizon: geo.cartesian3.fromDegrees(lon, lat, HORIZON_LIFT_M), scale: ghostMapsIconScale(style), @@ -201,6 +203,11 @@ export function createGhostMapsDisplay( let removeCull = null; const lastCamera = geo.cartesian3.create(Number.NaN, 0, 0); const request = () => engine.render.request(LAYER_ID); + const surface = createSurfacePoints(engine, { + groups: () => + [...paints].map((paint) => ({ set: paint.sprites, points: paint.pins })), + request, + }); // Hides the placemarks beyond the horizon; true when any show changed. function cull(paint) { @@ -289,6 +296,7 @@ export function createGhostMapsDisplay( pin.show = engine.projector.horizonVisible(pin.horizon); writePins(paint); syncCull(); + surface.place(); } return { @@ -341,6 +349,7 @@ export function createGhostMapsDisplay( syncCull(); request(); }; + paint.dispose = dispose; let ready; try { commitGround(paint); @@ -378,9 +387,10 @@ export function createGhostMapsDisplay( destroy() { if (destroyed) return; destroyed = true; + surface.destroy(); removeCull?.(); removeCull = null; - paints.clear(); + for (const paint of paints) paint.dispose(); }, }; } diff --git a/src/layers/ghostMaps/render/display.test.mjs b/src/layers/ghostMaps/render/display.test.mjs index c3a06aea7..a2b0a6ac2 100644 --- a/src/layers/ghostMaps/render/display.test.mjs +++ b/src/layers/ghostMaps/render/display.test.mjs @@ -414,3 +414,79 @@ test('an edition destroyed while its icons decode draws no placemarks', async () /destroyed/, ); }); + +test('placemarks follow the loaded surface and refine after tiles settle', async (t) => { + const engine = createFakeEngine(); + t.after(() => engine.destroy()); + engine.projector.worldToWindow = () => ({ x: 100, y: 100 }); + let height = 350; + let loaded = false; + engine.fake.script('terrainHeight', () => height); + engine.fake.script('tilesLoaded', () => loaded); + const display = createGhostMapsDisplay(engine, iconSeams()); + t.after(() => display.destroy()); + const paint = display.draw( + 'cip', + new Map([ + [ + 0, + { + style: {}, + geometries: [{ type: 'point', position: [0, 0] }], + }, + ], + ]), + PRODUCT, + markerImage, + ); + t.after(() => paint.destroy()); + await paint.ready; + const altitude = () => { + const { store } = engine.fake.moverSet('s2-ghostmaps:cip:1:pins'); + return geo.cartographic.fromCartesian({ + x: store.positions[0], + y: store.positions[1], + z: store.positions[2], + }).height; + }; + assert.ok(Math.abs(altitude() - 350) < 0.01); + engine.fake.frames(1); + height = 420; + loaded = true; + engine.fake.frames(1); + assert.ok(Math.abs(altitude() - 420) < 0.01); +}); + +test('a large edition measures only on-screen placemarks, a few per pass', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const engine = createFakeEngine(); + t.after(() => engine.destroy()); + let onScreen = 0; + engine.projector.worldToWindow = () => + onScreen-- > 0 ? { x: 100, y: 100 } : undefined; + let samples = 0; + engine.fake.script('terrainHeight', () => { + samples++; + return 30; + }); + const display = createGhostMapsDisplay(engine, iconSeams()); + t.after(() => display.destroy()); + const features = new Map( + Array.from({ length: 2000 }, (_, index) => [ + index, + { + style: {}, + geometries: [{ type: 'point', position: [index / 100, 0] }], + }, + ]), + ); + onScreen = 40; + const paint = display.draw('cip', features, PRODUCT, markerImage); + t.after(() => paint.destroy()); + await paint.ready; + assert.ok(samples > 0 && samples <= 16, `first pass sampled ${samples}`); + t.mock.timers.tick(0); + t.mock.timers.tick(0); + t.mock.timers.tick(0); + assert.equal(samples, 40, 'every on-screen placemark, none off screen'); +}); diff --git a/src/layers/infrastructure/render/display.js b/src/layers/infrastructure/render/display.js index a8a623895..380d39521 100644 --- a/src/layers/infrastructure/render/display.js +++ b/src/layers/infrastructure/render/display.js @@ -658,6 +658,7 @@ export function createInfrastructureDisplay( }) : null, }; + row.__localBaseCartesian = row.base; rows.set(row.id, row); services.registerEntityContext?.(row, { id: row.pickId, diff --git a/src/layers/infrastructure/render/inViewContext.test.mjs b/src/layers/infrastructure/render/inViewContext.test.mjs new file mode 100644 index 000000000..1a43426a8 --- /dev/null +++ b/src/layers/infrastructure/render/inViewContext.test.mjs @@ -0,0 +1,84 @@ +// get_entity_context in_view must list a datacenter/dam drawn in the middle of +// the screen. The infrastructure display registers its context rows without +// __localBaseCartesian, which visibleEntityContexts (src/voice/gevActions.js) +// requires, so every infrastructure record is skipped. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../../geo/index.js'; +import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; +import { registerEntityContext } from '../../../data/contextStore.js'; +import { createGevActionRunner } from '../../../voice/gevActions.js'; +import { createInfrastructureDisplay } from './display.js'; + +globalThis.window ||= { setTimeout, clearTimeout, requestIdleCallback: null }; +globalThis.window.addEventListener ||= () => {}; +globalThis.window.removeEventListener ||= () => {}; +globalThis.window.dispatchEvent ||= () => true; + +const DAMS = { + id: 'local-dams', + name: 'Dams', + color: '#0088ff', + source: 'USACE', + osmDerived: true, + labels: false, +}; + +test('in_view lists an on-screen dam', async (t) => { + const engine = createFakeEngine({ + longitude: geo.math.toRadians(-97.7), + latitude: geo.math.toRadians(30.2), + altitude: 20_000, + width: 800, + height: 600, + }); + const display = createInfrastructureDisplay(engine, { + config: DAMS, + services: { + registerEntityContext, + overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, + }, + }); + t.after(() => { + display.destroy(); + engine.destroy(); + }); + display.show({ + features: [ + { + type: 'Feature', + id: 'dam-1', + properties: { name: 'Runtime Dam' }, + geometry: { type: 'Point', coordinates: [-97.7, 30.2] }, + }, + ], + records: [ + { + id: 'dam-1', + lon: -97.7, + lat: 30.2, + properties: { name: 'Runtime Dam' }, + geometry: { type: 'Point', coordinates: [-97.7, 30.2] }, + }, + ], + }); + const dataManager = { + layers: new Map(), + isEnabled: (id) => id === 'local-dams', + getAll: () => [ + { id: 'local-dams', name: 'Dams', enabled: true, stats: {} }, + ], + }; + const runner = createGevActionRunner({ + viewer: null, + engine, + styleManager: { runImmediateNavigation: (n, go) => go() }, + dataManager, + }); + const result = await runner('get_entity_context', { scope: 'in_view' }); + assert.equal(result.ok, true); + assert.ok( + result.visible.some((entry) => String(entry.id).includes('dam-1')), + `visible: ${JSON.stringify(result.visible)}`, + ); +}); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 56fca7736..7a2602847 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -113,6 +113,7 @@ export function createLifecycle({ parts.selection.startMissionZoomAnchor( launch, layerState._missionZoomRange, + { resuming: true }, ); } layerState._resumeRenderer?.(); @@ -124,6 +125,9 @@ export function createLifecycle({ layerState._waitForRenderer ??= new Promise((resolve) => { layerState._resumeRenderer = resolve; }); + layerState._replayCameraHeading = layerState._replayTracks.get( + layerState._replayCameraLaunchId, + )?.lastCameraHeading; layerState._replayCameraToken++; layerState._replayCameraRemover?.(); layerState._missionZoomAnchorRemover?.(); @@ -177,7 +181,7 @@ export function createLifecycle({ getRenderer()?.setVisible(true); parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); // Snapshot the existing follow before the mission flight releases it. - await parts.ingestion.captureSatelliteDependency(); + const dependency = parts.ingestion.captureSatelliteDependency(); if (layerState._lastUpdate === null) { // A new attempt at an index that never loaded starts from loading. layerState._lastError = null; @@ -205,6 +209,7 @@ export function createLifecycle({ layerState._explicitSelection, ); else parts.selection.setSelectedMission(null, false); + await dependency; }, async disable() { diff --git a/src/layers/launches/lifecycleSnapshot.test.mjs b/src/layers/launches/lifecycleSnapshot.test.mjs index b8a20b7bd..6e3fe5615 100644 --- a/src/layers/launches/lifecycleSnapshot.test.mjs +++ b/src/layers/launches/lifecycleSnapshot.test.mjs @@ -38,3 +38,57 @@ test('missions preserve the satellite target before their focus flight releases else globalThis.document = previous; } }); + +test('missions begin their entry flight while the satellite catalog loads', async () => { + let resolve; + const pending = new Promise((done) => { + resolve = done; + }); + let flew = false; + const lifecycle = createLifecycle({ + state: { _engine: {} }, + services: { render: {} }, + getRenderer: () => ({ setVisible() {} }), + parts: { + orbitRendering: { syncMissionOrbitPrimitiveVisibility() {} }, + selection: { + focusFullGlobe() { + flew = true; + }, + setSelectedMission() {}, + }, + ingestion: { captureSatelliteDependency: () => pending }, + }, + }); + const enabling = lifecycle.methods._enableBody(); + assert.equal(flew, true); + resolve(); + await enabling; +}); + +test('detaching retains the active replay heading before replacing its track', () => { + const state = { + _renderer: { destroy() {} }, + _replayCameraLaunchId: 'mission', + _replayTracks: new Map([['mission', { lastCameraHeading: 1.2 }]]), + _missionOverlayRecords: new Map(), + _satelliteTelemetry: new Map(), + }; + const noop = () => {}; + const lifecycle = createLifecycle({ + state, + services: { render: {} }, + parts: { + launchPad: { destroyLaunchPadZonePrimitive: noop }, + orbitRendering: { removeMissionOrbitPrimitives: noop }, + overlays: { + clearMissionOverlaySources: noop, + destroyReplayVehicleOverlay: noop, + }, + rendering: { removeMissionElements: noop }, + }, + }); + lifecycle.methods.detachRenderer(); + assert.equal(state._replayTracks.size, 0); + assert.equal(state._replayCameraHeading, 1.2); +}); diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index db51f635b..98ab6adf9 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -367,7 +367,10 @@ export function createReplay({ const initialHeading = parts.camera.replayInitialCameraHeading( track.ascentPath, ); - track.lastCameraHeading = initialHeading; + track.lastCameraHeading = + resuming && Number.isFinite(layerState._replayCameraHeading) + ? layerState._replayCameraHeading + : initialHeading; syncReplayButton(); parts.overlays.syncMissionOverlayEntries(); if (!resuming) { diff --git a/src/layers/launches/replay.test.mjs b/src/layers/launches/replay.test.mjs index ded0a6a0f..b3d10b1d2 100644 --- a/src/layers/launches/replay.test.mjs +++ b/src/layers/launches/replay.test.mjs @@ -45,3 +45,25 @@ test('changing replay speed during the countdown keeps its real-time liftoff', ( // Mission time already flown is still preserved across the change. assert.equal(state._animationStarts.get('flying'), 960_000); }); + +test('resuming a replay restores its retained chase heading', () => { + const { state } = replayRig(); + state._engine = { + frames: { preUpdate: { addEventListener: () => () => {} } }, + }; + state._launches = [{ id: 'mission' }]; + state._replayCameraLaunchId = 'mission'; + state._replayCameraHeading = 1.2; + const track = { ascentPath: [], ascentDurationSec: 10 }; + state._replayTracks.set('mission', track); + const replay = createReplay({ + state, + parts: { + camera: { replayInitialCameraHeading: () => 2.3 }, + panel: { publishPanel: noop }, + overlays: { syncMissionOverlayEntries: noop }, + }, + }); + assert.equal(replay.startMissionReplay('mission', { resuming: true }), true); + assert.equal(track.lastCameraHeading, 1.2); +}); diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js index 77b3bd3d9..a34f6fd57 100644 --- a/src/layers/launches/selection.js +++ b/src/layers/launches/selection.js @@ -107,7 +107,11 @@ export function createSelection({ layerState._engine.camera.release(); } - function startMissionZoomAnchor(launch, initialRange) { + function startMissionZoomAnchor( + launch, + initialRange, + { resuming = false } = {}, + ) { if (!layerState._engine || !launch || layerState._replayCameraLaunchId) return; stopMissionZoomAnchor(); @@ -118,6 +122,12 @@ export function createSelection({ ); layerState._missionZoomAnchorId = launch.id; layerState._missionZoomRange = range; + if (resuming) { + layerState._engine.camera.lookAtTransform( + geo.transforms.eastNorthUpToFixedFrame(target), + ); + return; + } layerState._engine.camera.lookAt(target, { heading: 0, pitch: parts.camera.missionZoomPitch(range), diff --git a/src/layers/launches/selection.test.mjs b/src/layers/launches/selection.test.mjs new file mode 100644 index 000000000..d88952e23 --- /dev/null +++ b/src/layers/launches/selection.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { createSelection } from './selection.js'; + +test('restoring a mission zoom anchor preserves the world camera pose', (t) => { + const engine = createFakeEngine(); + t.after(() => engine.destroy()); + const state = { _engine: engine }; + const selection = createSelection({ + state, + services: { geometry: {} }, + parts: { camera: { missionZoomPitch: () => -0.5 } }, + }); + const before = engine.camera.pose(); + selection.startMissionZoomAnchor( + { id: 'mission', lon: 30, lat: 15 }, + 200000, + { resuming: true }, + ); + const after = engine.camera.pose(); + assert.deepEqual(after.position, before.position); + assert.equal(after.heading, before.heading); + assert.equal(after.pitch, before.pitch); +}); diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index 58b146428..ee3aac7ee 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -135,6 +135,7 @@ export function createState({ services }) { state._replaySpeed = 1; state._replayTracks = new Map(); + state._replayCameraHeading = null; state._missionOverlayRecords = new Map(); diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js index c41dc6b0c..94d43d6c9 100644 --- a/src/layers/localAdsb/models.js +++ b/src/layers/localAdsb/models.js @@ -159,7 +159,7 @@ export function createLocalAdsbModels({ view.engine, id, marker.position, - () => [marker.entity, model].filter(Boolean), + () => [id], ); if (!Number.isFinite(height)) return null; const carto = geo.cartographic.fromCartesian( diff --git a/src/layers/localAdsb/models.test.mjs b/src/layers/localAdsb/models.test.mjs new file mode 100644 index 000000000..31734e54e --- /dev/null +++ b/src/layers/localAdsb/models.test.mjs @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../geo/index.js'; +import { createLocalAdsbModels } from './models.js'; + +test('ground snapping excludes the aircraft by its engine pick id', async () => { + const position = geo.cartesian3.fromDegrees(0, 0, 0); + const model = { ready: true }; + const renderer = { + models: { + loadFleet: async () => model, + add() {}, + remove() {}, + destroy() {}, + setPose() {}, + }, + view: { + camera: { + positionWC: geo.cartesian3.fromDegrees(0, 0, 100), + positionCartographic: { height: 100 }, + }, + frustumTester: () => () => true, + horizon: () => ({ isPointVisible: () => true }), + engine: {}, + }, + }; + const exclusions = []; + const models = createLocalAdsbModels({ + renderer, + color: {}, + resolveAsset: (url) => url, + groundSnap: { + heightFor(engine, id, position, exclude) { + exclusions.push(exclude()); + return 0; + }, + }, + }); + const markers = new Map([ + [ + 'local-adsb:abc123', + { position, entity: {}, klass: 'airliner', onGround: true }, + ], + ]); + models.frame(markers, { models3d: true }, 1000); + await new Promise(setImmediate); + models.frame(markers, { models3d: true }, 1100); + assert.deepEqual(exclusions, [['local-adsb:abc123']]); + models.destroy(); +}); diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index 3562a5385..67ba8b28b 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -1,13 +1,6 @@ import { createAircraftSceneBinding } from '../aircraft/sceneBinding.js'; -import { JET_MODEL_URL } from './policy.js'; -export function createLifecycle({ - flightState, - services, - parts, - layer, - resolveAsset, -}) { +export function createLifecycle({ flightState, services, parts, layer }) { const { clearFocusTarget } = services.focus; const { holdContinuousRender, releaseContinuousRender } = services.render; const { ensureGeoidReady } = services.geoid; @@ -19,7 +12,6 @@ export function createLifecycle({ flightState, services, parts, - resolveModel: () => resolveAsset(JET_MODEL_URL), enable: () => methods.enable(), }); const methods = { diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 69bab83d2..ccd10c6e7 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -673,9 +673,7 @@ export function createQueries({ // follow): asking to track it again follows it again (as flights). const entity = flightState._trackedEntity; if (entity && !flightState._renderer?.follow.isFollowing(entity)) { - flightState._trackedCameraFrameStop?.(); - flightState._trackedCameraFrameStop = - flightState._renderer.follow.refocus(entity); + flightState._renderer.follow.refocus(entity); } return parts.tracking._publishTrackedSelection(id, origin); } @@ -750,9 +748,7 @@ export function createQueries({ !flightState._renderer.follow.isFollowing(flightState._trackedEntity) ) return false; - flightState._trackedCameraFrameStop?.(); - flightState._trackedCameraFrameStop = - flightState._renderer.follow.refocus(flightState._trackedEntity); + flightState._renderer.follow.refocus(flightState._trackedEntity); parts.tracking._publishTrackedSelection(id, origin); return true; }, diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 8c937dce9..81a732947 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -530,7 +530,6 @@ export function createRendering({ model.show = false; flightState._renderer.models.add(model); flightState._models.set(icao24, model); - flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard } /** Remove the 3D model for ONE aircraft (removal / track handoff). @@ -697,13 +696,7 @@ export function createRendering({ } flightState._trackedModel = m; flightState._trackedModelLoading = false; - // A good load retires this selection's failure budget — a contact that - // recovers after a transient blip is not one attempt from giving up. - flightState._trackedModelFailIcao = null; - flightState._trackedModelFailCount = 0; - flightState._trackedModelRetryAtMs = 0; flightState._renderer.models.add(m); - flightState._planeModelLoaded = true; }) .catch((err) => { if (gen !== flightState._trackedModelGen) return; // superseded load — not this selection's failure @@ -712,6 +705,17 @@ export function createRendering({ }); return; } + if (flightState._trackedModel?.failed) { + // The engine could not load the glTF: the billboard stays the visual + // and the load is tried again after the backoff. + const { url } = flightState._trackedModel.spec ?? {}; + _releaseTrackedModel(); + parts.tracking._noteTrackedModelLoadFailure( + url, + new Error('model failed to load'), + ); + return; + } if (flightState._trackedModel) { // Keep the transform current while GPU resources are still loading. Cesium // can flip ready during scene update after this callback; waiting for ready @@ -735,6 +739,13 @@ export function createRendering({ spec.headingOffsetDeg, ); if (!flightState._trackedModel.ready) return; + // A model that loaded retires this selection's failure budget: a contact + // that recovers after a transient blip is not one attempt from giving up. + if (flightState._trackedModelFailIcao === flightState._trackedIcao) { + flightState._trackedModelFailIcao = null; + flightState._trackedModelFailCount = 0; + flightState._trackedModelRetryAtMs = 0; + } const view = flightState._renderer.view; flightState._trackedModel.scale = trackedModelScaleForPixelCap({ baseScale: spec.scale, diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 738374c45..5cbabe436 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -31,6 +31,17 @@ export function createMilitarySnapshotRenderer({ groundFloor; const { sampleMeshFloorCells } = meshFloor; const { registerMilitaryIcaos } = militaryRegistry; + // A low contact is horizon-culled from 12 m up: its ground can lie below + // the ellipsoid. + function setCullPosition(id, meta) { + flightState._cullPositions.set( + id, + meta.renderAltitudeM < 10 + ? geo.cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12) + : null, + ); + } + function presentContact(icao24, meta, position, prevMeta, groundFlipped) { const isTracked = icao24 === flightState._trackedIcao; if (flightState._billboards.has(icao24)) { @@ -98,6 +109,7 @@ export function createMilitarySnapshotRenderer({ meta.rawLat, meta.renderAltitudeM, ); + setCullPosition(id, meta); presentContact(id, meta, position, null, false); } } @@ -130,12 +142,7 @@ export function createMilitarySnapshotRenderer({ meta.renderAltitudeM, ); if (groundFlipped) flightState._groundSnap.forget(icao24); - flightState._cullPositions.set( - icao24, - meta.renderAltitudeM < 10 - ? geo.cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12) - : null, - ); + setCullPosition(icao24, meta); const isTracked = icao24 === flightState._trackedIcao; // Append to position history stamped with the FEED's fix epoch. diff --git a/src/layers/military/state.js b/src/layers/military/state.js index 9dac0abd0..d04cf921a 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -70,18 +70,6 @@ export function createFlightState({ source, services }) { flightState._lastModelCapWarnMs = 0; // throttle the "more planes in view than the cap" console notice - // The tracked entity's billboard goes transparent (rather than hidden) once the model takes - // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its - // alpha after the GLB is preloaded so the model is ready to render the instant the billboard - // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not - // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. - - flightState._planeModelLoaded = false; - - /** @type {object|null} retained preload that keeps the glTF cache warm */ - - flightState._preloadModel = null; - /** Last limb taper per billboard, retained across class/ground/cockpit repaints. */ flightState._billboardLimbScale = new WeakMap(); @@ -116,10 +104,6 @@ export function createFlightState({ source, services }) { flightState._trackedEntity = null; - /** Disposes the single active tracked-camera framing owner. */ - - flightState._trackedCameraFrameStop = null; - /** @type {object|null} Standalone 3D model for the tracked aircraft — NOT a graphic on * _trackedEntity, so viewer.trackedEntity's follow-camera always has a ready bounding sphere * (a model graphic reports PENDING until loaded, which freezes the centering on 3D-toggle). */ diff --git a/src/layers/military/testing.js b/src/layers/military/testing.js index 5344dd57e..761db81d7 100644 --- a/src/layers/military/testing.js +++ b/src/layers/military/testing.js @@ -152,6 +152,20 @@ export function createTesting({ await parts.rendering._ensureModel(icao24); return flightState._models.get(icao24) || null; } + + /** The renderer handles one contact draws with, for QA probes that tamper + * with them: its glyph and fleet model, and the tracked glyph and model + * while it is tracked. */ + + function _aircraftHandlesForTest(icao24) { + const tracked = icao24 === flightState._trackedIcao; + return { + sprite: flightState._billboards.get(icao24) ?? null, + model: flightState._models.get(icao24) ?? null, + trackedSprite: tracked ? flightState._trackedEntity : null, + trackedModel: tracked ? flightState._trackedModel : null, + }; + } return { _setTrackedMilitaryRefreshStateForTest, _setMilitaryTrackingRefreshOutcomeForTest, @@ -164,5 +178,6 @@ export function createTesting({ _trackedBillboardColorForTest, _driveFleetModelHandoffForTest, _ensureFleetModelForTest, + _aircraftHandlesForTest, }; } diff --git a/src/layers/osmPlaces/render/display.js b/src/layers/osmPlaces/render/display.js index 32913b2a5..73c90c268 100644 --- a/src/layers/osmPlaces/render/display.js +++ b/src/layers/osmPlaces/render/display.js @@ -2,8 +2,7 @@ // engine.surface, engine.camera), the same on every engine: 9 px discs in // the place colour with a dark 2 px outline, on top only within 2.5 km of // the camera. The contract has no ground clamping, so each point sits on the -// sampled surface; heights are read again after each camera move, and -// shortly after a draw that found no surface under a place yet. +// sampled surface; visible points refine after camera moves and tile loads. import * as geo from '../../../geo/index.js'; import { rgbaFromCss } from '../../shared/color.js'; @@ -16,6 +15,8 @@ const INITIAL_CAPACITY = 256; // While the surface under a place has not loaded, its height is asked again. const RESAMPLE_MS = 500; const RESAMPLE_TRIES = 20; +const SAMPLES_PER_PASS = 16; +const SAMPLE_BUDGET_MS = 4; // Under the moving contacts (src/data/spriteOrder.js), as map data. const POINT_ORDER = -1; @@ -34,7 +35,8 @@ export function createOsmPlacesDisplay(engine) { records = [], destroyed = false, retry = null, - tries = 0; + revision = 0; + const heights = new Map(); function ensure(count) { if (set && count <= capacity) return; set?.destroy(); @@ -56,21 +58,73 @@ export function createOsmPlacesDisplay(engine) { const shown = visible ? records : []; ensure(shown.length); const sampling = engine.surface.supported; - let unsampled = false; + let sampleTime = 0; + const now = Date.now(); + const size = engine.host.size(); + let samples = 0; + let delay = Infinity; shown.forEach((record, index) => { - const sampled = sampling - ? engine.surface.sampleHeight( + let cached = heights.get(record); + if (!cached || cached.revision !== revision) { + cached = { + height: cached?.height ?? 0, + revision, + attempts: 0, + nextAt: 0, + resolved: false, + }; + heights.set(record, cached); + } + let position = geo.cartesian3.fromDegrees( + record.lon, + record.lat, + cached.height + HEIGHT_OFFSET_M, + ); + const screen = engine.projector.horizonVisible(position) + ? engine.projector.worldToWindow(position) + : null; + const inView = + screen && + screen.x >= 0 && + screen.y >= 0 && + screen.x < size.width && + screen.y < size.height; + if ( + sampling && + inView && + !cached.resolved && + cached.attempts < RESAMPLE_TRIES + ) { + if ( + now >= cached.nextAt && + samples < SAMPLES_PER_PASS && + sampleTime < SAMPLE_BUDGET_MS + ) { + const started = performance.now(); + const sampled = engine.surface.sampleHeight( geo.cartographic.fromDegrees(record.lon, record.lat), [set], - ) - : undefined; - if (!Number.isFinite(sampled)) unsampled = true; + ); + sampleTime += performance.now() - started; + samples++; + cached.attempts++; + cached.nextAt = now + RESAMPLE_MS; + // Cesium samples kilometres underground while tiles stream. + if (Number.isFinite(sampled) && sampled >= -500 && sampled <= 9000) { + cached.height = sampled; + cached.resolved = true; + position = geo.cartesian3.fromDegrees( + record.lon, + record.lat, + sampled + HEIGHT_OFFSET_M, + ); + } + } + if (!cached.resolved && cached.attempts < RESAMPLE_TRIES) + delay = Math.min(delay, Math.max(0, cached.nextAt - now)); + } set.set(index, { - position: geo.cartesian3.fromDegrees( - record.lon, - record.lat, - (Number.isFinite(sampled) ? sampled : 0) + HEIGHT_OFFSET_M, - ), + position, color, show: true, pickId: osmPlacePickId(record), @@ -78,12 +132,18 @@ export function createOsmPlacesDisplay(engine) { }); set.setCount(shown.length); set.commit(); - if (unsampled && sampling && tries++ < RESAMPLE_TRIES) - retry = setTimeout(draw, RESAMPLE_MS); + if (Number.isFinite(delay)) retry = setTimeout(draw, delay); } - const removeMoveEnd = engine.camera.moveEnd.addEventListener(() => { - tries = 0; + function refine() { + revision++; if (visible && records.length) draw(); + } + const removeMoveEnd = engine.camera.moveEnd.addEventListener(refine); + let tilesLoaded = engine.surface.tilesLoaded(); + const removeSurface = engine.frames.preRender.addEventListener(() => { + const loaded = engine.surface.tilesLoaded(); + if (loaded && !tilesLoaded) refine(); + tilesLoaded = loaded; }); return { setVisible(show) { @@ -93,7 +153,8 @@ export function createOsmPlacesDisplay(engine) { }, replace(next) { records = next.slice(); - tries = 0; + heights.clear(); + revision++; draw(); }, destroy() { @@ -101,6 +162,8 @@ export function createOsmPlacesDisplay(engine) { destroyed = true; clearTimeout(retry); removeMoveEnd(); + removeSurface(); + heights.clear(); set?.destroy(); set = null; records = []; diff --git a/src/layers/osmPlaces/render/display.test.mjs b/src/layers/osmPlaces/render/display.test.mjs index 4759b8a91..2c6a11207 100644 --- a/src/layers/osmPlaces/render/display.test.mjs +++ b/src/layers/osmPlaces/render/display.test.mjs @@ -15,7 +15,11 @@ const places = [ ]; function fakeHarness() { - const engine = createFakeEngine(); + const engine = createFakeEngine({ + longitude: geo.math.toRadians(85.31), + latitude: geo.math.toRadians(27.7), + altitude: 20000, + }); return { name: 'the fake engine', engine, @@ -75,7 +79,7 @@ for (const makeHarness of HARNESSES) { }); test(`places sit 2 m over the sampled surface, read again after a move (${name})`, (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); const h = makeHarness(); const display = createOsmPlacesDisplay(h.engine); const position = () => @@ -139,3 +143,51 @@ for (const makeHarness of HARNESSES) { } }); } + +test('places refine loaded heights after tiles settle', (t) => { + const engine = createFakeEngine(); + t.after(() => engine.destroy()); + engine.projector.worldToWindow = () => ({ x: 100, y: 100 }); + engine.projector.horizonVisible = () => true; + let height = 50; + let loaded = false; + engine.fake.script('sampleHeight', () => height); + engine.fake.script('tilesLoaded', () => loaded); + const display = createOsmPlacesDisplay(engine); + t.after(() => display.destroy()); + display.setVisible(true); + display.replace([{ id: 'place', lon: 0, lat: 0 }]); + engine.fake.frames(1); + height = 70; + loaded = true; + engine.fake.frames(1); + const { positions } = engine.fake.moverSet('osm-places').store; + assert.ok( + Math.abs( + heightOf({ x: positions[0], y: positions[1], z: positions[2] }) - 72, + ) < 0.01, + ); +}); + +test('large searches only sample visible places within a per-pass budget', (t) => { + const engine = createFakeEngine(); + t.after(() => engine.destroy()); + engine.projector.worldToWindow = () => ({ x: 100, y: 100 }); + engine.projector.horizonVisible = () => true; + let samples = 0; + engine.fake.script('sampleHeight', () => { + samples++; + return 30; + }); + const display = createOsmPlacesDisplay(engine); + t.after(() => display.destroy()); + display.setVisible(true); + display.replace( + Array.from({ length: 500 }, (_, i) => ({ id: String(i), lon: 0, lat: 0 })), + ); + assert.ok(samples > 0 && samples <= 16, `sampled ${samples}`); + engine.projector.worldToWindow = () => undefined; + samples = 0; + engine.camera.moveEnd.raiseEvent(); + assert.equal(samples, 0, 'off-screen places are not sampled'); +}); diff --git a/src/layers/recentImagery/detachedGetStats.test.mjs b/src/layers/recentImagery/detachedGetStats.test.mjs new file mode 100644 index 000000000..ae0af3e9b --- /dev/null +++ b/src/layers/recentImagery/detachedGetStats.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { createRecentImageryLayer } from './index.js'; +import { + fakeCatalog, + fakeRenderer, + fakeThumbnails, + manualTimers, + BOX, + CANDIDATES, + settle, +} from './testDoubles.mjs'; + +test('getStats between detachRenderer and attachRenderer does not throw', async () => { + const engine = createFakeEngine(); + engine.camera.viewRectangle = () => undefined; + const timers = manualTimers(); + const catalog = fakeCatalog(); + const rendererFactory = () => { + const r = fakeRenderer(); + r.resolveHost = () => ({ collection: {}, kind: 'globe' }); + r.viewCentre = () => undefined; + r.zoomToFit = () => {}; + return r; + }; + const layer = createRecentImageryLayer({ + catalog, + rendererFactory, + thumbnails: fakeThumbnails(), + now: () => new Date('2026-09-21T15:00:00Z'), + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + layer.init(null, { engine }); + layer.enable(); + layer.setBox(BOX); + catalog.resolveLast(CANDIDATES); + await settle(); + assert.doesNotThrow(() => layer.getStats(), 'attached'); + await layer.detachRenderer(); + assert.doesNotThrow( + () => layer.getStats(), + 'detached: stats poll during an engine switch', + ); +}); diff --git a/src/layers/recentImagery/index.js b/src/layers/recentImagery/index.js index 5646a661a..0aa5fa562 100644 --- a/src/layers/recentImagery/index.js +++ b/src/layers/recentImagery/index.js @@ -343,6 +343,7 @@ export function createRecentImageryLayer({ } function syncHost() { + if (!renderer) return false; const next = renderer.resolveHost(); const changed = (next.identity || next.collection) !== diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index 2337bac1c..137935b36 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -422,10 +422,11 @@ export function createControls({ state: layerState, services, parts, source }) { if (params.showOrbits !== undefined) { layerState._params.showOrbits = params.showOrbits !== false; for (const path of layerState._orbitPaths.values()) - path.primitive.show = satelliteVisualsVisible( - layerState._enabled, - layerState._params.showOrbits, - ); + if (path.primitive) + path.primitive.show = satelliteVisualsVisible( + layerState._enabled, + layerState._params.showOrbits, + ); parts.labels._syncIssOverlay(); } if (catalogChanged && catalog === 'dense') { diff --git a/src/layers/satellites/detachedToggle.test.mjs b/src/layers/satellites/detachedToggle.test.mjs new file mode 100644 index 000000000..3c394cd88 --- /dev/null +++ b/src/layers/satellites/detachedToggle.test.mjs @@ -0,0 +1,98 @@ +// Disabling Satellites, or changing showOrbits, between detachRenderer and +// attachRenderer (an engine switch) must not throw on the dropped orbit rings. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { createApplicationSatellites } from '../../app/layers/satellites.js'; +import { createSatellitesDisplay } from './render/display.js'; +import { appClock } from '../../time/clock.js'; +import { createLifecycle } from './lifecycle.js'; + +const ISS_TLE = `ISS (ZARYA) +1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927 +2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537`; +const EPOCH_MS = Date.UTC(2008, 8, 20, 12, 30, 0); + +async function setup(t) { + const previousDocument = globalThis.document; + globalThis.document = Object.assign(new EventTarget(), { + getElementById: () => null, + }); + appClock.freeze(EPOCH_MS); + const layer = createApplicationSatellites({ + source: { + async readGroup(group) { + return { + ok: true, + status: 200, + text: group === 'stations' ? ISS_TLE : '', + stale: false, + }; + }, + }, + renderer: { create: ({ engine }) => createSatellitesDisplay(engine) }, + }); + t.after(() => { + try { + layer.destroy(); + } catch {} + appClock.reset(); + globalThis.document = previousDocument; + }); + await layer.init(null, { engine: createFakeEngine() }); + layer.enable(); + await layer.update(); + return layer; +} + +test('satellites: disable between detach and attach does not throw', async (t) => { + const layer = await setup(t); + layer.detachRenderer(); + assert.doesNotThrow(() => layer.disable()); +}); + +test('satellites: showOrbits between detach and attach does not throw', async (t) => { + const layer = await setup(t); + layer.detachRenderer(); + assert.doesNotThrow(() => layer.setParams({ showOrbits: false })); +}); + +test('restored orbit rings receive their current rotation before the first frame', () => { + let rotated = 0; + const path = { basePositions: [], color: {} }; + const state = { + _points: new Map(), + _orbitPaths: new Map([[25544, path]]), + _params: { showOrbits: true }, + _enabled: true, + }; + const lifecycle = createLifecycle({ + state, + services: { + focus: {}, + render: {}, + picking: {}, + renderer: { + create: () => ({ + createPoints: () => ({}), + createOrbit: () => ({ modelMatrix: [] }), + }), + }, + }, + parts: { + rendering: { + _updateOrbitPathRotations(date) { + rotated++; + assert.ok(date instanceof Date); + assert.ok(path.primitive); + }, + }, + }, + }); + lifecycle.methods.attachRenderer(null, { + engine: {}, + waitForCameraRestore: true, + }); + assert.equal(rotated, 1); + assert.equal(path.primitive.show, true); +}); diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index ab3482347..521fc5dee 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -1,3 +1,4 @@ +import { appClock } from '../../time/clock.js'; import { ISS_NORAD, ISS_OVERLAY_SOURCE_ID } from './policy.js'; export function createLifecycle({ @@ -162,6 +163,7 @@ export function createLifecycle({ path.primitive.show = layerState._enabled && layerState._params.showOrbits; } + parts.rendering._updateOrbitPathRotations(new Date(appClock.now())); // Source work can publish as soon as GPU collections exist. Restoring // share-selected camera state may itself wait for that publication. layerState._resumeRenderer?.(); @@ -197,10 +199,11 @@ export function createLifecycle({ ); // Orbit ring primitives + persistent ISS host label — show them for (const path of layerState._orbitPaths.values()) - path.primitive.show = parts.controls.satelliteVisualsVisible( - layerState._enabled, - layerState._params.showOrbits, - ); + if (path.primitive) + path.primitive.show = parts.controls.satelliteVisualsVisible( + layerState._enabled, + layerState._params.showOrbits, + ); parts.labels._syncIssOverlay(); // Re-attach input handlers and preRender propagation if (layerState._renderer) parts.interaction._installClickHandler(); @@ -229,7 +232,7 @@ export function createLifecycle({ releaseContinuousRender('satellites', layerState._engine); if (layerState._pointCollection) layerState._pointCollection.show = false; for (const path of layerState._orbitPaths.values()) - path.primitive.show = false; + if (path.primitive) path.primitive.show = false; parts.tracking._clearTracking(); parts.labels._syncIssOverlay(); // Remove click handler + keydown listener + preRender propagation while disabled diff --git a/src/layers/shared/render/moverBank.js b/src/layers/shared/render/moverBank.js index 91ec5038b..e279511f8 100644 --- a/src/layers/shared/render/moverBank.js +++ b/src/layers/shared/render/moverBank.js @@ -89,6 +89,10 @@ export function createMoverBank(engine, { options, write, onCommit }) { bank.touch(); }, visible: () => visible, + /** The same handles in a new set (one that loads its assets again). */ + rebuild() { + if (set) build(capacity); + }, touch() { if (!dirty) { dirty = true; diff --git a/src/layers/shared/render/surfacePoints.js b/src/layers/shared/render/surfacePoints.js new file mode 100644 index 000000000..ed44db022 --- /dev/null +++ b/src/layers/shared/render/surfacePoints.js @@ -0,0 +1,127 @@ +// Stands map points on the rendered surface (tiles, else terrain), the same +// on every engine: the contract has no ground clamp. On the globe that +// surface is the terrain, read from its loaded tiles; only 3D tiles, which +// hide the globe, need sampleHeight, a pick render on Cesium. Only points +// on screen are measured, a few per pass, as a layer can hold thousands. +// They are measured again after each camera move and once the surface's +// tiles have loaded; a point whose height holds is not written again, so a +// parked camera asks for no frames. + +import * as geo from '../../../geo/index.js'; + +const SAMPLES_PER_PASS = 16; +const SAMPLE_BUDGET_MS = 4; +const HEIGHT_EPSILON_M = 0.01; +// A point not placed yet projects from the ellipsoid, away from where it +// will stand: the screen test takes this much of the view around it too. +const SCREEN_MARGIN = 0.25; + +// Dead Sea to Everest, with geoid slack: Cesium samples kilometres +// underground while tiles stream. +const plausible = (height) => + Number.isFinite(height) && height >= -500 && height <= 9000; + +/** + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {object} options + * @param {() => Iterable<{ set: object | null, points: object[], exclude?: object[] }>} options.groups + * Mover sets with the points they draw, point i as instance i, and what + * the samples skip (the set by default; a layer adds the rest it draws + * over the points). A point has `cartographic` and `position`, which a + * placement replaces (keeping the height in `surfaceHeight`); one with + * `show === false` is skipped. + * @param {() => void} options.request Asks for a frame after a placement. + * @returns {{ place: () => void, destroy: () => void }} `place()` measures + * the points not yet placed on this surface (new points, for example). + */ +export function createSurfacePoints(engine, { groups, request }) { + let revision = 0; + let timer = null; + let destroyed = false; + let tilesLoaded = engine.surface.tilesLoaded(); + + function onScreen(position, size) { + const screen = engine.projector.worldToWindow(position); + const x = size.width * SCREEN_MARGIN; + const y = size.height * SCREEN_MARGIN; + return ( + screen && + screen.x >= -x && + screen.y >= -y && + screen.x < size.width + x && + screen.y < size.height + y + ); + } + + // Points off screen are passed over until the next move or tile load. + function place() { + clearTimeout(timer); + timer = null; + if (destroyed) return; + const size = engine.host.size(); + const tiles = engine.maps?.photorealActive?.() === true; + const started = performance.now(); + let samples = 0; + let moved = false; + for (const { set, points, exclude = [set] } of groups()) { + if (!set) continue; + let changed = false; + for (let index = 0; index < points.length; index++) { + const point = points[index]; + if (point.surfaceRevision === revision) continue; + if ( + samples >= SAMPLES_PER_PASS || + performance.now() - started >= SAMPLE_BUDGET_MS + ) { + if (changed) set.commit(); + if (changed || moved) request(); + timer = setTimeout(place, 0); + return; + } + point.surfaceRevision = revision; + if (point.show === false || !onScreen(point.position, size)) continue; + samples++; + let height = tiles + ? engine.surface.sampleHeight(point.cartographic, exclude) + : undefined; + if (!plausible(height)) + height = engine.surface.terrainHeight(point.cartographic); + if (!plausible(height)) continue; + if (Math.abs(height - point.surfaceHeight) < HEIGHT_EPSILON_M) continue; + point.surfaceHeight = height; + point.position = geo.cartesian3.fromRadians( + point.cartographic.longitude, + point.cartographic.latitude, + height, + ); + set.set(index, { position: point.position }); + changed = true; + } + if (changed) set.commit(); + moved ||= changed; + } + if (moved) request(); + } + + function refine() { + revision++; + place(); + } + const removeMoveEnd = engine.camera.moveEnd.addEventListener(refine); + const removeFrame = engine.frames.preRender.addEventListener(() => { + const loaded = engine.surface.tilesLoaded(); + if (loaded && !tilesLoaded) refine(); + tilesLoaded = loaded; + }); + + return { + place, + destroy() { + if (destroyed) return; + destroyed = true; + clearTimeout(timer); + removeMoveEnd(); + removeFrame(); + }, + }; +} diff --git a/src/layers/shared/render/surfacePoints.test.mjs b/src/layers/shared/render/surfacePoints.test.mjs new file mode 100644 index 000000000..d04f254a2 --- /dev/null +++ b/src/layers/shared/render/surfacePoints.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../../geo/index.js'; +import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; +import { createSurfacePoints } from './surfacePoints.js'; + +function setup(t, count = 3) { + const engine = createFakeEngine(); + t.after(() => engine.destroy()); + engine.projector.worldToWindow = () => ({ x: 100, y: 100 }); + const set = engine.movers.createSet({ + id: 'points', + kind: 'point', + capacity: count, + point: { sizePx: 6 }, + }); + const points = Array.from({ length: count }, (_, index) => ({ + cartographic: geo.cartographic.fromDegrees(index, 0), + position: geo.cartesian3.fromDegrees(index, 0), + })); + points.forEach((point, index) => + set.set(index, { position: point.position, show: true }), + ); + set.setCount(count); + set.commit(); + const surface = createSurfacePoints(engine, { + groups: () => [{ set, points }], + request: () => engine.render.request('points'), + }); + t.after(() => surface.destroy()); + const heights = () => + points.map( + (point) => geo.cartographic.fromCartesian(point.position).height, + ); + return { engine, surface, heights }; +} + +test('on the globe points stand on the terrain without a pick sample', (t) => { + const { engine, surface, heights } = setup(t); + let picks = 0; + engine.fake.script('sampleHeight', () => { + picks++; + return 900; + }); + engine.fake.script('terrainHeight', () => 40); + surface.place(); + assert.equal(picks, 0); + for (const height of heights()) assert.ok(Math.abs(height - 40) < 0.01); +}); + +test('under 3D tiles points stand on the sampled tiles', async (t) => { + const { engine, surface, heights } = setup(t); + await engine.maps.setPhotorealTiles({ id: 'photoreal' }); + engine.fake.script('sampleHeight', () => 55); + engine.fake.script('terrainHeight', () => 40); + surface.place(); + for (const height of heights()) assert.ok(Math.abs(height - 55) < 0.01); +}); + +test('a parked camera asks for no frames once the points stand', (t) => { + const { engine, heights } = setup(t); + let terrain = 40; + engine.fake.script('terrainHeight', () => terrain); + engine.fake.script('tilesLoaded', () => true); + engine.camera.moveEnd.raiseEvent(); + assert.ok(Math.abs(heights()[0] - 40) < 0.01); + const before = engine.fake.renderRequests; + engine.camera.moveEnd.raiseEvent(); + engine.fake.frames(3); + assert.equal(engine.fake.renderRequests, before, 'same heights, no frame'); + terrain = 46; + engine.camera.moveEnd.raiseEvent(); + assert.ok(engine.fake.renderRequests > before, 'a changed height draws'); + assert.ok(Math.abs(heights()[0] - 46) < 0.01); +}); diff --git a/src/layers/streetLevel/render/display.js b/src/layers/streetLevel/render/display.js index f1345cfb7..efabbcfb5 100644 --- a/src/layers/streetLevel/render/display.js +++ b/src/layers/streetLevel/render/display.js @@ -179,14 +179,11 @@ export function createStreetLevelDisplay(engine) { function paint() { if (destroyed) return; - // The surface settled (or the map stack changed): place every sprite - // again on the globe, where terrain is cheap; under tiles only the - // ones not on them yet. + // Loaded detail can replace the surface under an already placed sprite. const loaded = engine.surface.tilesLoaded(); const nowTiles = tilesShown(); if ((loaded && settled === false) || nowTiles !== tiles) { - for (const row of rows) - if (!nowTiles || !row.exact || nowTiles !== tiles) place(row); + for (const row of rows) place(row); dirty = true; } settled = loaded; diff --git a/src/layers/streetLevel/render/display.test.mjs b/src/layers/streetLevel/render/display.test.mjs index abc83138d..ed54250e9 100644 --- a/src/layers/streetLevel/render/display.test.mjs +++ b/src/layers/streetLevel/render/display.test.mjs @@ -199,13 +199,13 @@ test('a sprite sits on the terrain; under Google 3D on the tiles, placed again w excluded.every((list) => Array.isArray(list)), 'its own sprites are left out of the sample', ); - // On the tiles now: a later settle samples nothing again. - const samples = excluded.length; + // A later settle can replace the coarse tiles under the sprite. + tiles = 45; fake.script('tilesLoaded', false); harness.frame(); fake.script('tilesLoaded', true); harness.frame(); - assert.equal(excluded.length, samples); + assert.ok(Math.abs(heightOf(google.drawn().position) - 45) < 1e-6); // An implausible sample (tiles still streaming) is not ground. tiles = -14_886; google.group.move(google.row, { lon: -121.4945 }); diff --git a/src/layers/submarineCables/render/display.js b/src/layers/submarineCables/render/display.js index b3dbc3486..4bb99c10b 100644 --- a/src/layers/submarineCables/render/display.js +++ b/src/layers/submarineCables/render/display.js @@ -2,8 +2,8 @@ import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js'; // The submarine cables on the globe contract, the same on every engine: // - cable paths: one engine.ground corridor set (2.5 px, each cable's own // colour at 0.92), draped on the active map stack's surface; -// - landing points: a point mover set at the ground (the 6 px GeoJSON pin's -// width and colour); +// - landing points: a point mover set on the sampled surface (the 6 px +// GeoJSON pin's width and colour); // - references: per cable and landing point, a tip point mover high enough // for a ~66 px stem, sized on the sweep cadence, with its label on the // shared world-overlay host; @@ -14,6 +14,7 @@ import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js'; import * as geo from '../../../geo/index.js'; import { isPointerFree } from '../../../data/inputOwnership.js'; import { rgbaFromCss } from '../../shared/color.js'; +import { createSurfacePoints } from '../../shared/render/surfacePoints.js'; import { featureLabel, featureReference, @@ -213,11 +214,10 @@ export function createCablesDisplay( } for (const set of tipSets.values()) set.commit(); if (markers) { - markerRows.forEach((row, index) => - markers.set(index, { - show: engine.projector.horizonVisible(row.base), - }), - ); + markerRows.forEach((row, index) => { + row.show = engine.projector.horizonVisible(row.base); + markers.set(index, { show: row.show }); + }); markers.commit(); } const winners = selectCableReferenceLabelWinners([...records.values()]); @@ -365,6 +365,7 @@ export function createCablesDisplay( items.push({ positions, ...style.path(feature), pickId: id }); for (const point of shapes.points) rows.push({ + cartographic: geo.cartographic.fromDegrees(point[0], point[1]), position: geo.cartesian3.fromDegrees(point[0], point[1]), base, color: style.marker, @@ -380,7 +381,7 @@ export function createCablesDisplay( id: `${ID}-markers`, kind: 'point', capacity: rows.length, - depthTest: 'normal', + depthTest: { disableWithinM: 2500 }, pickable: true, point: { sizePx: MARKER_PX, outlinePx: 0 }, }); @@ -457,7 +458,21 @@ export function createCablesDisplay( try { build(next); dataset = next; + const surface = createSurfacePoints(engine, { + // The reference tips and stems stand over the landing points. + groups: () => [ + { + set: markers, + points: markerRows, + exclude: [markers, stemBatch, ...tipSets.values()].filter( + Boolean, + ), + }, + ], + request: wake, + }); removers.push( + surface.destroy, engine.input.on('click', onClick, { pick: true }), engine.camera.moveEnd.addEventListener(() => { gate.markDirty(); @@ -465,6 +480,7 @@ export function createCablesDisplay( }), engine.frames.preRender.addEventListener(sweep), ); + surface.place(); publisher.show(); // A hide cleared the host source: an identical cohort republishes. resetSignature(); diff --git a/src/layers/submarineCables/render/display.test.mjs b/src/layers/submarineCables/render/display.test.mjs index 41246a545..d9d1b04a5 100644 --- a/src/layers/submarineCables/render/display.test.mjs +++ b/src/layers/submarineCables/render/display.test.mjs @@ -672,3 +672,53 @@ test('edge:telegeography-submarine-cables: a click on a cable beside its landing `reference at ${geo.math.toDegrees(target.longitude)}°`, ); }); + +test('landing point markers stand on the coastal 3D tiles, pickable at close range', async (t) => { + const engine = createFakeEngine(); + t.after(() => engine.destroy()); + await engine.maps.setPhotorealTiles({ id: 'photoreal' }); + engine.projector.worldToWindow = () => ({ x: 100, y: 100 }); + let height = 40; + let loaded = false; + engine.fake.script('sampleHeight', () => height); + engine.fake.script('tilesLoaded', () => loaded); + const display = createCablesDisplay(engine, { + overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, + }); + t.after(() => display.destroy()); + display.show({ + cables: [], + landingPoints: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'coast', + properties: {}, + geometry: { type: 'Point', coordinates: [0, 0] }, + }, + ], + }, + }); + const { options } = engine.fake.moverSet( + `${CABLE_OVERLAY_SOURCE_ID}-markers`, + ); + assert.deepEqual(options.depthTest, { disableWithinM: 2500 }); + assert.equal(options.pickable, true); + const altitude = () => { + const { store } = engine.fake.moverSet( + `${CABLE_OVERLAY_SOURCE_ID}-markers`, + ); + return geo.cartographic.fromCartesian({ + x: store.positions[0], + y: store.positions[1], + z: store.positions[2], + }).height; + }; + assert.ok(Math.abs(altitude() - 40) < 0.01, 'on the tiles, not under them'); + engine.fake.frames(1); + height = 46; + loaded = true; + engine.fake.frames(1); + assert.ok(Math.abs(altitude() - 46) < 0.01, 'refined once tiles load'); +}); diff --git a/src/layers/traffic/render/belowEllipsoid.test.mjs b/src/layers/traffic/render/belowEllipsoid.test.mjs new file mode 100644 index 000000000..ad3392f26 --- /dev/null +++ b/src/layers/traffic/render/belowEllipsoid.test.mjs @@ -0,0 +1,48 @@ +// Traffic on roads below the WGS84 ellipsoid (negative ellipsoidal heights: +// southern India and Sri Lanka, Florida, the Dead Sea) stays drawn while the +// camera is low above the road, as Cesium's PointPrimitiveCollection did on main. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../../geo/index.js'; +import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs'; +import { nearFarScale } from '../../shared/color.js'; +import { SIM_DOT_COLOR } from '../policy.js'; +import { TRAFFIC_SET_IDS as IDS, createTrafficDisplay } from './display.js'; + +// Colombo: the ground stands about 93 m below the ellipsoid. +const LON = 79.85; +const LAT = 6.93; +const GROUND_M = -93; + +for (const makeHarness of GROUND_HARNESSES) { + test(`a car on a road below the ellipsoid stays drawn under a low camera (${makeHarness().name})`, (t) => { + const h = makeHarness(); + t.after(() => h.destroy()); + // Camera 50 m above the road, looking at a car 500 m ahead. + h.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(LON, LAT, GROUND_M + 50), + }); + const display = createTrafficDisplay(h.engine); + t.after(() => display.destroy()); + display.drawDots([ + { + point: { + position: geo.cartesian3.fromDegrees( + LON, + LAT + 0.0045, + GROUND_M + 1.5, + ), + pixelSize: 5, + color: SIM_DOT_COLOR, + show: true, + scaleByDistance: nearFarScale(100, 1.5, 8000, 0.3), + translucencyByDistance: nearFarScale(100, 1, 10000, 0), + outlineColor: null, + outlineWidth: 0, + }, + }, + ]); + const { store } = h.engine.fake.moverSet(IDS.fill); + assert.equal(store.count, 1, 'the car is drawn'); + }); +} diff --git a/src/layers/traffic/render/display.js b/src/layers/traffic/render/display.js index 4ece80163..9f73a41b9 100644 --- a/src/layers/traffic/render/display.js +++ b/src/layers/traffic/render/display.js @@ -101,7 +101,6 @@ export function createTrafficDisplay(engine) { // A dot at size 0 (fading in, recycled at a road end) draws nothing, // its outline included. if (!point?.show || !(point.pixelSize > 0) || !point.position) continue; - if (!engine.projector.horizonVisible(point.position)) continue; const distance = geo.cartesian3.distance(point.position, camera); const alpha = nearFarValue(point.translucencyByDistance, distance); if (alpha < MIN_TRANSLUCENCY) continue; diff --git a/src/layers/traffic/surface.js b/src/layers/traffic/surface.js index 18482cc6e..f888f72ea 100644 --- a/src/layers/traffic/surface.js +++ b/src/layers/traffic/surface.js @@ -181,25 +181,32 @@ const surfaceId = (object) => { /** * Name what heights are sampled against: the photoreal tiles shown, or the - * globe under its base imagery (the contract does not name the terrain; a map - * stack switch replaces the base imagery with it). A map-provider switch - * changes it and invalidates every cached height. + * globe under its base imagery and terrain (a terrain that loads after a + * failure keeps the imagery). A map-provider or terrain switch changes it and + * invalidates every cached height. */ const surfaceKeys = new WeakMap(); export function trafficSurfaceKey(engine) { let state = surfaceKeys.get(engine); if (!state) { - state = { tiles: false, source: null, key: '' }; + state = { tiles: false, source: null, terrain: 0, key: '' }; surfaceKeys.set(engine, state); } const tiles = engine.maps.photorealActive(); const current = engine.maps.current(); const source = tiles ? current.photoreal : current.baseImagery; - if (state.tiles !== tiles || state.source !== source || !state.key) { + const terrain = tiles ? 0 : engine.maps.terrainRevision(); + if ( + state.tiles !== tiles || + state.source !== source || + state.terrain !== terrain || + !state.key + ) { state.tiles = tiles; state.source = source; + state.terrain = terrain; // Allocate only when the actual map surface changes, never per frame. - state.key = `${tiles ? 'tiles' : 'globe'}:${surfaceId(source)}`; + state.key = `${tiles ? 'tiles' : `globe:${terrain}`}:${surfaceId(source)}`; } return state.key; } diff --git a/src/layers/traffic/surface.test.mjs b/src/layers/traffic/surface.test.mjs index 1a8ebf205..21211619f 100644 --- a/src/layers/traffic/surface.test.mjs +++ b/src/layers/traffic/surface.test.mjs @@ -343,7 +343,7 @@ test('rendered road depth is geographically checked, bounded, and avoids mesh re assert.equal(renderedRoadHeight(engine, -97, 30, 0), undefined); }); -test('surface keys reuse identity without building arrays or strings on unchanged frames', () => { +test('surface keys reuse identity without building arrays or strings on unchanged frames', async () => { const engine = surfaceEngine(); const first = trafficSurfaceKey(engine); const remove = observeTrafficSurface(engine); @@ -386,6 +386,13 @@ test('surface keys reuse identity without building arrays or strings on unchange globe, 'a map stack switch on the globe is a new surface', ); + const terrainBefore = trafficSurfaceKey(engine); + await engine.maps.setTerrain({ kind: 'ellipsoid' }); + assert.notEqual( + trafficSurfaceKey(engine), + terrainBefore, + 'a terrain that loads under the same imagery is a new surface', + ); remove(); remove(); assert.equal(engine.frames.postRender.numberOfListeners, 0); diff --git a/src/layers/transit/render/style.js b/src/layers/transit/render/style.js index 542a9b04b..c385c66f1 100644 --- a/src/layers/transit/render/style.js +++ b/src/layers/transit/render/style.js @@ -1,5 +1,5 @@ // How the transit vehicles and the selected vehicle's trail draw -// (engine.movers sprite sets, engine.lines). +// (engine.movers sprite sets, engine.ground corridors). import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index a9e3681d3..a122f3ccd 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -287,8 +287,7 @@ export function createLifecycle({ } function bindVisibleScene(activeViewer, scene) { - // Pick-ownership (H2): vessel picks carry the record OBJECT as their id; - // the registry resolver reduces it to the record's mmsi (a string key). + // Only MMSI picks present in the live record map belong to this layer. registerPickOwner( 'ais-live-vessels', (pickedId) => state.records.byMmsi.has(pickedId), diff --git a/src/layers/weather/render/display.js b/src/layers/weather/render/display.js index c26a12fce..277ce067f 100644 --- a/src/layers/weather/render/display.js +++ b/src/layers/weather/render/display.js @@ -578,6 +578,7 @@ export function createWeatherDisplay( requestRender: () => engine.render.request('weather'), viewRectangle: () => engine.camera.viewRectangle(), moveEnd: engine.camera.moveEnd, + maxTextureSize: engine.render.maxTextureSize(), createSurface: (frame) => createRaisedWeatherSurface(engine, product, frame), }); diff --git a/src/layers/weather/shellRendering.js b/src/layers/weather/shellRendering.js index 8e31ced97..a56fe37ad 100644 --- a/src/layers/weather/shellRendering.js +++ b/src/layers/weather/shellRendering.js @@ -90,12 +90,22 @@ export function detailWindow(footprint, bounds, previous = null) { return previous && sameEdges(previous, next) ? previous : next; } +// Halves, down to 1024 px wide, on devices with a smaller texture limit. +function fitTexture({ width, height }, limit) { + while (limit > 0 && width > limit && width > 1024) { + width /= 2; + height /= 2; + } + return { width, height }; +} + /** Observed weather on 3D Tiles: one raised shell per product and one * full-extent image per frame. The previous image stays until the next is ready. * Inside a window around the view the same surface samples a sharper image of * that area, so the two images share one mesh and never blend. The surfaces * are the engine's (`createSurface`, render/display.js), as are the render - * requests, the view footprint and the camera's move-end event. */ + * requests, the view footprint, the camera's move-end event and the GPU's + * texture limit. */ export function createWeatherShell({ product, height = WEATHER_SHELL_HEIGHTS[product], @@ -111,11 +121,12 @@ export function createWeatherShell({ requestRender, viewRectangle, moveEnd, + maxTextureSize = Infinity, }) { if (!Object.hasOwn(WEATHER_IMAGE_SIZES, product)) throw new TypeError('Unknown weather product'); - const size = { ...WEATHER_IMAGE_SIZES[product] }; - const detailSize = { ...WEATHER_DETAIL_SIZE }; + const size = fitTexture(WEATHER_IMAGE_SIZES[product], maxTextureSize); + const detailSize = fitTexture(WEATHER_DETAIL_SIZE, maxTextureSize); const infrared = product === 'clouds' || product === 'clouds-regional'; // Global infrared contrast depends on the requested extent, so a window would // not match the image around it; the globe host also shows one mosaic. diff --git a/src/layers/weather/shellRendering.test.mjs b/src/layers/weather/shellRendering.test.mjs index 1503f9d7f..f94a8c1af 100644 --- a/src/layers/weather/shellRendering.test.mjs +++ b/src/layers/weather/shellRendering.test.mjs @@ -526,6 +526,21 @@ test('image size follows the product maximum', async () => { } }); +test('a GPU with a 2048 px texture limit gets 2048 px images', async () => { + const h = harness({ + maxTextureSize: 2048, + decodeImage: async () => ({ width: 2048, height: 1024, close() {} }), + }); + assert.deepEqual(h.shell.getDiagnostics().imageSize, { + width: 2048, + height: 1024, + }); + assert.equal(await show(h, times[0]), true); + const url = new URL(h.fetches[0].url, 'https://example.test'); + assert.equal(url.searchParams.get('size'), '2048x1024'); + h.shell.clear(); +}); + test('a host without imagery hides the shell, keeps the frame and refuses work', async () => { const h = harness(); assert.equal(await show(h, times[0]), true); diff --git a/src/main.js b/src/main.js index 74d00a4ee..8c3573315 100644 --- a/src/main.js +++ b/src/main.js @@ -5,7 +5,12 @@ import { import { createRendererCache } from './standalone/rendererCache.js'; import { describeError } from './standalone/errors.js'; import { createEngineAddress } from './standalone/engineSwitch.js'; -import { ENGINE_STORAGE_KEY, engineOfChoice } from './app/engineChoice.js'; +import { + DEFAULT_ENGINE_CHOICE, + ENGINE_STORAGE_KEY, + engineOfChoice, + resolveEngineKind, +} from './app/engineChoice.js'; import { createSceneSwitcher } from './standalone/sceneSwitcher.js'; import { createLayerSession } from './app/layerSession.js'; import { createSessionDiagnostics } from './app/sessionDiagnostics.js'; @@ -73,7 +78,7 @@ window.addEventListener('pagehide', (event) => { // settle() hands the address back to whichever engine then runs. window.addEventListener('popstate', () => { if (!engineSwitch) return; - if (pageEngineKind() !== engineSwitch.state().engine) + if (requestedEngineKind() !== engineSwitch.state().engine) application ?.getComponents() .controls?.styleManager?.shareLinkManager?.holdNavigation?.(); @@ -120,6 +125,13 @@ function present() { button.disabled = busy; } +// Product names: never translated. +const ENGINE_NAMES = { + cesium: 'Cesium', + navara: 'Navara', + 'navara-hd': 'Navara HD', +}; + // No engine runs after a failed recovery. The error stays on the loading // screen with an engine picker, so a new attempt needs no reload. function offerEngineRetry() { @@ -129,11 +141,7 @@ function offerEngineRetry() { group.className = 'engine-switch-retry'; group.setAttribute('role', 'group'); group.setAttribute('aria-label', t('Globe Engine')); - for (const [kind, name] of [ - ['cesium', 'Cesium'], - ['navara', 'Navara'], - ['navara-hd', 'Navara HD'], - ]) { + for (const [kind, name] of Object.entries(ENGINE_NAMES)) { const button = document.createElement('button'); button.type = 'button'; button.dataset.engine = kind; @@ -157,8 +165,21 @@ function report(error) { loading.classList.remove('hidden'); } +// A globe that failed to start while another runs: say which one runs. +function showStartFailure(error, running) { + const style = liveComponents()?.controls.styleManager; + style?._showToast( + t('Globe engine failed to start. Running {engine}: {message}', { + engine: ENGINE_NAMES[running] ?? running, + message: describeError(error), + }), + ); + return Boolean(style); +} + function select(next) { - void engineSwitch?.select(next); + if (engineSwitch) void engineSwitch.select(next); + else if (!starting) void start(next, { rememberChoice: true }); } // Keep the last visible frame while the first visit initializes the other renderer. @@ -177,14 +198,32 @@ function keepGlobeFrame(engine) { return frame; } -const initialKind = pageEngineKind(); -createApplication(initialKind, false) - .start() - .then(() => { +let initialKind = pageEngineKind(); +let starting = false; +const requestedEngineKind = () => + resolveEngineKind({ search: location.search, fallback: initialKind }); + +function remember(kind) { + try { + localStorage.setItem(ENGINE_STORAGE_KEY, kind); + } catch { + // The current URL still carries the choice when storage is blocked. + } +} + +async function start(kind, { fallback = false, rememberChoice = false } = {}) { + starting = true; + try { + await createApplication(kind, false).start(); + if (rememberChoice) { + initialKind = kind; + remember(kind); + createEngineAddress({ location, history }).restore(kind); + } session.count('attachments'); engineSwitch = createSceneSwitcher({ - kind: initialKind, - requested: pageEngineKind, + kind, + requested: requestedEngineKind, signal, getApplication: () => application, startApplication: (kind, transition) => @@ -203,25 +242,36 @@ createApplication(initialKind, false) flush: () => liveComponents()?.controls.styleManager.shareLinkManager?.flush(), }), - remember(kind) { - try { - localStorage.setItem(ENGINE_STORAGE_KEY, kind); - } catch { - // The current URL still carries the choice when storage is blocked. - } - }, + remember, onChange: present, - onFailure(error, { recovered }) { - const style = recovered && liveComponents()?.controls.styleManager; - if (style) return style._showToast(describeError(error)); + onFailure(error, { recovered, kind: running }) { + if (recovered && showStartFailure(error, running)) return; report(error); offerEngineRetry(); }, }); + withdrawEngineRetry(); + loading.classList.add('hidden'); present(); // A history visit made while the first engine started. void engineSwitch.reconcile(); - }) - .catch(report); + } catch (error) { + await application?.destroy().catch(report); + if (fallback && kind !== DEFAULT_ENGINE_CHOICE) { + // A stored choice that cannot start would fail on every visit. + await start(DEFAULT_ENGINE_CHOICE, { rememberChoice: true }); + showStartFailure(error, DEFAULT_ENGINE_CHOICE); + } else { + report(error); + offerEngineRetry(); + } + } finally { + starting = false; + } +} + +void start(initialKind, { + fallback: !new URLSearchParams(location.search).has('engine'), +}); export { application }; diff --git a/src/mapStartup.test.mjs b/src/mapStartup.test.mjs index 42154ab33..513b88c82 100644 --- a/src/mapStartup.test.mjs +++ b/src/mapStartup.test.mjs @@ -81,7 +81,7 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) { engineOfChoice: (choice) => (choice === 'cesium' ? 'cesium' : 'navara'), createStandalonePlaceSearch: () => ({}), createStandaloneCatalog: () => ({}), - // Cesium's displays are in the main chunk: nothing to load. + // The startup path draws no layer: its displays need not load. loadLayerRenderers: async () => {}, createGoogleTokenSource: () => createGoogleTokenSource({ fetchImpl }), selectMapStartupRoute, diff --git a/src/maps/defaultSources.js b/src/maps/defaultSources.js index 18af37136..cfe82a415 100644 --- a/src/maps/defaultSources.js +++ b/src/maps/defaultSources.js @@ -8,8 +8,8 @@ import { import { terrainSourceFor } from '../globe/cesium/terrain.js'; import { t } from '../i18n/index.js'; -const NAVARA_ION_IMAGERY_REASON = - 'Bing imagery is not available on the experimental Navara engine'; +const navaraIonImageryReason = () => + t('Bing imagery is not available on the experimental Navara engine'); /** * Select sources and setup guidance without putting provider branches in the @@ -53,7 +53,7 @@ export function createDefaultMapSources({ available: !ionImageryOnNavara && (!descriptor.requiresIon || hasIon), // Read when shown, so it follows the locale. unavailableReason: ionImageryOnNavara - ? NAVARA_ION_IMAGERY_REASON + ? navaraIonImageryReason : descriptor.requiresIon ? () => t('Needs {envVars} — add it in Provider Settings', { diff --git a/src/maps/sourceFactories.test.mjs b/src/maps/sourceFactories.test.mjs index a2b7a90fb..e347e6cc1 100644 --- a/src/maps/sourceFactories.test.mjs +++ b/src/maps/sourceFactories.test.mjs @@ -416,7 +416,7 @@ test('on the Navara engine photoreal keys on the browser key and ion imagery is ]) { for (const id of ['bing-aerial', 'bing-labels']) { assert.equal(byId(registry, id).available, false); - assert.match(byId(registry, id).unavailableReason, /Navara/); + assert.match(byId(registry, id).unavailableReason(), /Navara/); } assert.equal(byId(registry, 'esri-imagery').available, true); assert.equal(byId(registry, 'osm').available, true); diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 49ab7e9d0..37bb4b154 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1148,12 +1148,6 @@ export function createWorldOverlay(engine) { }; } - /** - * True when a candidate survives the explicit horizon/viewport policies. - * The horizon test prefers `entry.cullPosition` when the source supplied one - * (see normalizeOverlayEntry) so a sub-ellipsoid render anchor is not - * false-hidden near the limb; everything else still uses the render position. - */ /** * Return true when a rectangle intersects any cached UI exclusion box. * `count` lets pooled buffers expose a live prefix without truncating. diff --git a/src/scenes/dataPacks/shapes.js b/src/scenes/dataPacks/shapes.js index 843e98e02..075945542 100644 --- a/src/scenes/dataPacks/shapes.js +++ b/src/scenes/dataPacks/shapes.js @@ -6,9 +6,6 @@ import * as geo from '../../geo/index.js'; const GRANULARITY_DEGREES = 1; function shape(positions, indices, st = null) { - const center = geo.cartesian3.create(); - for (const p of positions) geo.cartesian3.add(center, p, center); - geo.cartesian3.divideByScalar(center, positions.length || 1, center); const values = new Float64Array(positions.length * 3); positions.forEach((p, i) => values.set([p.x, p.y, p.z], i * 3)); return { @@ -17,7 +14,6 @@ function shape(positions, indices, st = null) { ...(st ? { st: { values: st } } : {}), }, indices: new Uint32Array(indices), - boundingSphere: { center }, }; } diff --git a/src/scenes/director.js b/src/scenes/director.js index 81664493e..8a40829d0 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -1282,6 +1282,7 @@ export class SceneDirector { return { started: false, reason: 'data-packs-unavailable' }; if (token.cancelled) return; if (seekState) { + if (this._sceneGate && !(await this._waitForScene(token))) return; this._setCameraView( seekState.camera || resolveCameraPose(scene, shot.camera), ); diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index f11347f93..6f2f8b9c4 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -2138,3 +2138,40 @@ for (const createHarness of GROUND_HARNESSES) { } }); } + +test('seek waits for a scene reattachment after loading data packs', async () => { + const { director, restore } = makeDirector(); + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + let waiting = false; + let activated = false; + director._applyDataPacks = async () => { + director._sceneGate = gate; + return true; + }; + director._waitForScene = async () => { + waiting = true; + await gate; + return true; + }; + director._activateInteractions = () => { + activated = true; + }; + try { + const seeking = director.seekScene('scene-1', 0.25); + for (let i = 0; i < 15; i++) await Promise.resolve(); + assert.equal(activated, false); + assert.equal(waiting, true); + director._sceneGate = null; + release(); + await seeking; + assert.equal(activated, true); + } finally { + director._sceneGate = null; + release(); + await director.destroy(); + restore(); + } +}); diff --git a/src/scenes/interactions.js b/src/scenes/interactions.js index 9ba7fb691..576dad323 100644 --- a/src/scenes/interactions.js +++ b/src/scenes/interactions.js @@ -121,8 +121,8 @@ export function createSceneInteractions({ handler = null; engine = nextEngine; if (registered) targets = registered; + container = engine?.host?.container ?? container; if (!panel || !engine) return; - container = engine.host?.container ?? container; update({ container }); bindInput(panel.owner); }, diff --git a/src/scenes/interactionsSceneSwitch.test.mjs b/src/scenes/interactionsSceneSwitch.test.mjs new file mode 100644 index 000000000..a5e5624a7 --- /dev/null +++ b/src/scenes/interactionsSceneSwitch.test.mjs @@ -0,0 +1,61 @@ +// An engine switch before any shot with actions: the next panel must render +// in the new engine's container, not the old (detached) one. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { useSceneInteractionsStore } from '../ui/stores/sceneInteractions.js'; +import SceneInteractions from '../ui/views/panels/SceneInteractions.vue'; +import { createSceneInteractions } from './interactions.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; + +const key = (featureId) => JSON.stringify(['qa', featureId]); +const SHOT = { + interactions: [ + { + id: 'card', + label: 'Read about it', + target: { packId: 'qa', featureId: 'point' }, + action: { type: 'card', text: 'A note.' }, + }, + ], +}; + +test('Scene actions open in the new engine container after a switch with no panel', async () => { + document.body.replaceChildren(); + const pinia = createPinia(); + const app = createApp(SceneInteractions).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + const makeEngine = () => { + const container = document.body.appendChild(document.createElement('div')); + const canvas = container.appendChild(document.createElement('canvas')); + return { container, engine: createFakeEngine({ container, canvas }) }; + }; + const first = makeEngine(); + const interactions = createSceneInteractions({ + engine: first.engine, + execute: async () => true, + store: useSceneInteractionsStore(pinia), + }); + // The switch (director.suspendScene / attachScene), no panel shown. + interactions.setScene(null); + first.container.remove(); + const second = makeEngine(); + const targets = new Map([[key('point'), {}]]); + interactions.setScene(second.engine, new Map(targets)); + interactions.activate(SHOT, new Map(targets)); + for (let i = 0; i < 4; i += 1) await nextTick(); + const inOld = Boolean( + first.container.querySelector('[data-director-interactions]'), + ); + const inNew = Boolean( + second.container.querySelector('[data-director-interactions]'), + ); + interactions.destroy(); + app.unmount(); + first.engine.destroy(); + second.engine.destroy(); + assert.equal(inOld, false, 'not in the old, detached container'); + assert.equal(inNew, true, 'in the new container'); +}); diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js index f462e9be0..7444920cc 100644 --- a/src/services/meshFloorSampler.js +++ b/src/services/meshFloorSampler.js @@ -69,16 +69,6 @@ export function createMeshFloorSampler({ // preference. Module-scope listener: the module only loads in the browser // bundle, and the subscription is idempotent for the app's lifetime. - /** - * Equirectangular distance (km) — same approximation the flights clamp uses. - */ - function _approxKm(lat1, lon1, lat2, lon2) { - const dLat = (lat2 - lat1) * 111.32; - const dLon = - (lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); - return Math.hypot(dLat, dLon); - } - /** * Samples the rendered mesh height for the coarse cells containing `points`, * one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per diff --git a/src/sharelink.js b/src/sharelink.js index 390b24ba1..5e1e2fba5 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -119,6 +119,9 @@ export class ShareLinkManager { // The hash this page last read or wrote. A location change back to it is // the app's own state, never an incoming share. this._knownHash = null; + // The hash last written from the live camera, with that camera's exact + // pose: the address rounds it (1e-4°, whole metres and degrees). + this._writtenView = null; this._unwatchNavigation = null; this._onNavigate = null; // Held while the page leaves this engine for another (holdNavigation). @@ -179,6 +182,8 @@ export class ShareLinkManager { parseInitialHash() { const hash = window.location.hash.slice(1); this._knownHash = hash; + const written = this._writtenView; + this._writtenView = null; const code = this._initialShareCode ?? parseSharePath(window.location.pathname)?.code ?? @@ -187,6 +192,9 @@ export class ShareLinkManager { const state = code === null ? this._parseShareHash(hash) : this._parseShareCode(code); if (!state) return null; + // Another engine reopening the address this manager wrote (an engine + // switch) takes the live view it was written from. + if (code === null && written?.hash === hash) state.liveView = written.view; this._beginIncomingRestore(state); return state; } @@ -398,7 +406,7 @@ export class ShareLinkManager { status: applyCamera ? 'superseded' : 'skipped', }); if (applyCamera && instant && this._isNavigationCurrent(navigationToken)) { - this.engine.camera.setView(view); + this.engine.camera.setView(state.liveView ?? view); this.engine.render.request('engine-switch'); cameraPromise = Promise.resolve({ status: 'applied' }); } else if (applyCamera && this._isNavigationCurrent(navigationToken)) { @@ -723,6 +731,17 @@ export class ShareLinkManager { if (this._sharpenFollowsEngine) params.set('siAuto', '1'); url.hash = params.toString(); this._knownHash = url.hash.slice(1); + const pose = this.engine.camera.pose(); + this._writtenView = { + hash: this._knownHash, + view: { + destination: geo.cartesian3.clone(pose.position), + orientation: { + direction: geo.cartesian3.clone(pose.direction), + up: geo.cartesian3.clone(pose.up), + }, + }, + }; history.replaceState(null, '', url.href); } diff --git a/src/sharelinkSwitchPose.test.mjs b/src/sharelinkSwitchPose.test.mjs new file mode 100644 index 000000000..8d367aee8 --- /dev/null +++ b/src/sharelinkSwitchPose.test.mjs @@ -0,0 +1,77 @@ +// An engine switch reopens the address the outgoing camera wrote; the +// incoming camera takes that camera's exact pose, as the address rounds it. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from './geo/index.js'; +import { createFakeEngine } from './globe/testing/fakeEngine.js'; +import { ShareLinkManager } from './sharelink.js'; + +function installTab(t) { + const prior = { window: globalThis.window, history: globalThis.history }; + const location = new URL('http://localhost/?engine=cesium'); + globalThis.window = Object.assign(new EventTarget(), { location }); + globalThis.history = { + replaceState(_state, _title, next) { + location.href = new URL(next, location.href).href; + }, + }; + t.after(() => Object.assign(globalThis, prior)); + return location; +} + +const live = { + position: geo.cartesian3.fromDegrees(-123.1, 48.38298, 1587.2), + heading: 0.3, + pitch: geo.math.toRadians(-40.017), + roll: 0, +}; + +function poseError(engine) { + const pose = engine.camera.pose(); + const angle = (key) => + Math.abs( + Math.atan2( + Math.sin(pose[key] - live[key]), + Math.cos(pose[key] - live[key]), + ), + ); + return { + metres: geo.cartesian3.distance(pose.position, live.position), + radians: Math.max(angle('heading'), angle('pitch'), angle('roll')), + }; +} + +test('an engine switch hands the live pose over, not the rounded address', async (t) => { + installTab(t); + const outgoing = createFakeEngine(); + outgoing.fake.setPose(live); + const manager = new ShareLinkManager(outgoing); + t.after(() => manager.destroy()); + manager.flush(); + manager.attachEngine(null); + const incoming = createFakeEngine(); + manager.attachEngine(incoming); + const state = manager.parseInitialHash(); + assert.equal(state.alt, 1587, 'the address rounds the view'); + await manager.applyState(state, { instant: true }); + const { metres, radians } = poseError(incoming); + assert.ok(metres < 1e-6, `${metres} m`); + assert.ok(radians < 1e-9, `${radians} rad`); +}); + +test('a visited entry restores its own address, not the live pose', async (t) => { + const location = installTab(t); + const outgoing = createFakeEngine(); + outgoing.fake.setPose(live); + const manager = new ShareLinkManager(outgoing); + t.after(() => manager.destroy()); + manager.flush(); + manager.attachEngine(null); + location.hash = 'v=2&lat=10.0000&lon=20.0000&alt=800&heading=0&pitch=-90'; + const incoming = createFakeEngine(); + manager.attachEngine(incoming); + await manager.applyState(manager.parseInitialHash(), { instant: true }); + const place = incoming.camera.pose().cartographic; + assert.ok(Math.abs(geo.math.toDegrees(place.latitude) - 10) < 1e-9); + assert.ok(Math.abs(place.height - 800) < 1e-6); +}); diff --git a/src/standalone/application.js b/src/standalone/application.js index 3ae576273..1675c9fb4 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -116,8 +116,7 @@ export function createStandaloneApplication({ throw new AggregateError(errors, 'The globe could not be released'); }; const signal = AbortSignal.any([controller.signal, lifetime]); - // The layers' engine-specific renderers of this engine (the layer - // manifest's import()s, none on Cesium) load beside its globe. + // Shared layer displays and this engine's backends load beside its globe. const layerRenderers = loadLayerRenderers(engineOfChoice(kind)); layerRenderers.catch(() => {}); try { diff --git a/src/standalone/engineSwitch.js b/src/standalone/engineSwitch.js index 9d07c89e8..7e04b2843 100644 --- a/src/standalone/engineSwitch.js +++ b/src/standalone/engineSwitch.js @@ -67,7 +67,10 @@ export function createEngineAddress({ location, history, flush = () => {} }) { * was where the incoming address agrees (always after a pick) and follows a * visited entry where it differs (handoffTargetEnabled). */ -export function handOffSession(session, { addressLayerIds = null } = {}) { +export function handOffSession( + session, + { addressLayerIds = null, visit = false } = {}, +) { const manager = session.manager; const listed = addressLayerIds ? new Set(addressLayerIds) : null; session.handoffLayers = new Map( @@ -77,6 +80,7 @@ export function handOffSession(session, { addressLayerIds = null } = {}) { entry: entry.module.attachRenderer ? entry : null, enabled: manager.isEffectivelyEnabled(id), durable: listed ? listed.has(id) : undefined, + visit, }, ]), ); diff --git a/src/standalone/sceneSwitcher.js b/src/standalone/sceneSwitcher.js index 6bab5b165..27dad0bbd 100644 --- a/src/standalone/sceneSwitcher.js +++ b/src/standalone/sceneSwitcher.js @@ -35,7 +35,7 @@ export function createSceneSwitcher({ return components.scene?.engine ? components : null; }; - async function leave(from, _to, _change, transaction) { + async function leave(from, _to, change, transaction) { const components = liveComponents(); if (components) { cover ??= keepFrame?.(components.scene.engine); @@ -44,6 +44,7 @@ export function createSceneSwitcher({ handOffSession(layerSession, { addressLayerIds: style.shareLinkManager?.currentLayerState?.()?.enabledLayerIds, + visit: change?.visit === true, }); renderers.retain(from); await getApplication().detachScene({ handoff: true }, transaction); diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index b77fdcae5..2bc5ed764 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -298,6 +298,7 @@ test('module analysis can tell dynamic imports from static ones', () => { imports: ['one', 'two', 'three', 'four'], browser: [], dynamicImports: ['three', 'four'], + staticImports: ['one', 'two'], }, ); }); @@ -506,3 +507,32 @@ test('symlink aliases do not hide renderer ownership', (t) => { ); assert.throws(() => checkImportDirections(root), /rendering|symlinks/); }); + +test('a dynamic import does not excuse a static import of the same engine', (t) => { + const { root, write } = fixture(t); + write( + 'src/app/scene.js', + "import '../globe/navara/index.js'; import('../globe/navara/index.js');", + ); + write('src/globe/navara/index.js'); + assert.throws( + () => checkImportDirections(root), + /only through a dynamic import/, + ); +}); + +test('Vue script and script setup follow SDK ownership', (t) => { + const { root, write } = fixture(t); + write( + 'src/ui/View.vue', + ``, + ); + assert.throws( + () => checkImportDirections(root), + (error) => { + assert.match(error.message, /cesium imports belong/); + assert.match(error.message, /three imports belong/); + return true; + }, + ); +}); diff --git a/src/tooling/panelBuild.test.mjs b/src/tooling/panelBuild.test.mjs index 0a7e1f364..ac06ed365 100644 --- a/src/tooling/panelBuild.test.mjs +++ b/src/tooling/panelBuild.test.mjs @@ -115,7 +115,7 @@ test("Cesium's workers get embedded files from memory and other requests from th } }); -test('the panel build leaves out the initial-chunk guard (it inlines Navara)', () => { +test('the single-chunk panel build leaves out the initial-chunk guard', () => { const kept = { name: 'kept' }; const config = panelBuildConfig({ build: { diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 78b718cd6..941711779 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -164,7 +164,6 @@ export class StyleManager extends ShellScene { this.placeSearch = placeSearch; this._navigation = new NavigationController({ - viewer, engine, tracking: { flightsLayer, @@ -196,7 +195,6 @@ export class StyleManager extends ShellScene { this._cockpitDisplayModeHandler = null; this._locationNavigation = new LocationNavigation({ - viewer, engine, placeSearch, navigation: this._navigation, diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index fb2d87709..ce17c3a98 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -2,10 +2,10 @@ import * as geo from '../geo/index.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; import { t } from '../i18n/index.js'; -// The camera is the globe engine's, and so is the follow: the followed -// entity is engine.camera.following().native (on Cesium also an entity the -// app put on viewer.trackedEntity). The viewer, when callers pass one, is -// read only for its clock. +// The camera is the globe engine's, and so is the follow. On Cesium the +// follow is the viewer's tracked entity (an app entity, or the proxy of an +// engine follow): its EntityView keeps whatever offset the camera takes in +// the entity's frame. Elsewhere the follow owns its offset. // Cesium's EasingFunction.CUBIC_IN_OUT (tween.js Cubic.InOut). function cubicInOut(amount) { @@ -13,17 +13,15 @@ function cubicInOut(amount) { return 0.5 * ((amount -= 2) * amount * amount + 2); } -function followedEntity(engine) { - const handle = engine?.camera.following?.(); - return handle?.native ?? null; +function followedEntity(engine, viewer) { + return engine?.camera.following?.() ? (viewer?.trackedEntity ?? null) : null; } -// An engine's own follow (no tracked entity: Navara): its handle, which -// knows its target and takes a new offset without ending the follow. +// A follow handle that takes a new offset without ending the follow. function engineFollow(engine, viewer) { const handle = engine?.camera.following?.(); return handle && - !followedEntity(engine) && + !followedEntity(engine, viewer) && typeof handle.target === 'function' && typeof handle.retarget === 'function' ? handle @@ -84,12 +82,12 @@ export function pickViewTarget(engine) { /** * Describe the camera as an orbit around the current viewport center. * @param {import('../globe/contract.js').GlobeEngine | null} engine - * @param {object | null} [viewer] The Cesium viewer whose tracked entity is - * the orbit target. + * @param {object | null} [viewer] The Cesium viewer: its tracked entity and + * the clock that samples it. */ export function readCameraTargetFrame(engine, viewer = null) { if (!engine) return null; - const entity = followedEntity(engine); + const entity = followedEntity(engine, viewer); const follow = engineFollow(engine, viewer); const center = entity ? trackedTarget(entity, viewer) @@ -133,8 +131,7 @@ function setCameraTargetFrame(engine, frame, viewer = null) { if (!engine || !frame?.target || !Number.isFinite(frame.range)) return false; const { camera } = engine; // Inside an engine's own follow: the new frame is the follow's offset (a - // camera command would end the follow; Cesium's tracked entity keeps its - // follow through lookAt below, so both engines keep following). + // camera command would end the follow). const follow = engineFollow(engine, viewer); if (follow) { const heading = normalizedHeading(frame.heading); @@ -150,19 +147,26 @@ function setCameraTargetFrame(engine, frame, viewer = null) { return true; } try { - const trackedTransform = followedEntity(engine) ? camera.transform() : null; - camera.lookAt(frame.target, { + const offset = { heading: normalizedHeading(frame.heading), pitch: frame.pitch, range: frame.range, - }); - if (trackedTransform) { - // Preserve EntityView's reference frame (including satellite frames). - // Its next update carries this new local offset along with the target. + }; + if (followedEntity(engine, viewer)) { + // lookAt is a camera command and would end an engine follow; the same + // frame through lookAtTransform does not. Then back to EntityView's + // reference frame (including satellite frames): its next update + // carries this new local offset along with the target. + const trackedTransform = camera.transform(); + camera.lookAtTransform( + geo.transforms.eastNorthUpToFixedFrame(frame.target), + offset, + ); camera.lookAtTransform(trackedTransform); engine.render.request('camera-orientation'); return true; } + camera.lookAt(frame.target, offset); const { position: destination, direction, up } = camera.pose(); camera.release(); if (destination && direction && up) { @@ -238,14 +242,14 @@ export function createCameraOrientationAnimator( if (!engine) return false; if (duration <= 0) return setCameraTargetFrame(engine, { ...frame, ...destination }, viewer); - const entity = followedEntity(engine); + const entity = followedEntity(engine, viewer); pending = destination; const start = now(); const headingDelta = geo.math.negativePiToPi( destination.heading - frame.heading, ); remove = engine.frames.preUpdate.addEventListener(() => { - if (engine.isDestroyed() || followedEntity(engine) !== entity) { + if (engine.isDestroyed() || followedEntity(engine, viewer) !== entity) { cancel(); return; } @@ -291,7 +295,7 @@ export function createCameraOrientationAnimator( * runNavigation: Function, * showToast?: Function, * animationMs?: number, - * }} options The viewer is only read for its clock and tracked entity. + * }} options The viewer (Cesium) is read for its tracked entity and clock. */ export function bindCameraOrientationControls({ viewer = null, diff --git a/src/ui/cameraOrientationControls.test.mjs b/src/ui/cameraOrientationControls.test.mjs index 3c1735a57..cef4a9004 100644 --- a/src/ui/cameraOrientationControls.test.mjs +++ b/src/ui/cameraOrientationControls.test.mjs @@ -64,6 +64,7 @@ function createViewer({ cameraPosition, target, pickPosition = null } = {}) { lookAtTransform(transform) { calls.push({ type: 'lookAtTransform', transform }); }, + cancelFlight() {}, setView(view) { calls.push({ type: 'setView', view }); }, diff --git a/src/ui/cameraOrientationNativeFollow.test.mjs b/src/ui/cameraOrientationNativeFollow.test.mjs new file mode 100644 index 000000000..337a71de8 --- /dev/null +++ b/src/ui/cameraOrientationNativeFollow.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { + readCameraTargetFrame, + resetCameraNorth, + toggleCameraTilt, +} from './cameraOrientationControls.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; + +// The aircraft and satellite displays pass native: handle to engine.camera.follow +// (src/layers/aircraft/render/display.js, src/layers/satellites/render/display.js). +const target = Cesium.Cartesian3.fromDegrees(-73.78, 40.64, 3000); +const aircraft = { + gevTrackedId: 'flight:abc', + gevDisplayPosition: () => target, +}; +const followAircraft = (engine) => + engine.camera.follow({ + native: aircraft, + position: () => target, + viewFrom: { x: 1400, y: -1400, z: 1000 }, + }); + +// A Cesium viewer with a real camera and the tracked-entity surface the +// Cesium follow drives (no EntityView: the follow's own frame holds). +function cesiumViewer() { + const scene = { + canvas: { clientWidth: 1200, clientHeight: 800, width: 1200, height: 800 }, + drawingBufferWidth: 1200, + drawingBufferHeight: 800, + mapProjection: new Cesium.GeographicProjection(Cesium.Ellipsoid.WGS84), + globe: { ellipsoid: Cesium.Ellipsoid.WGS84, pick: () => target }, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + mode: Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + pickPositionSupported: false, + requestRender() {}, + preRender: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postRender: new Cesium.Event(), + screenSpaceCameraController: { inertiaZoom: 0.8, minimumZoomDistance: 1 }, + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + let tracked; + const trackedEntityChanged = new Cesium.Event(); + const viewer = { + scene, + camera, + canvas: scene.canvas, + clock: { currentTime: Cesium.JulianDate.now(), onTick: new Cesium.Event() }, + entities: { add: (options) => new Cesium.Entity(options), remove() {} }, + isDestroyed: () => false, + trackedEntityChanged, + get trackedEntity() { + return tracked; + }, + set trackedEntity(entity) { + if (entity === tracked) return; + tracked = entity; + trackedEntityChanged.raiseEvent(entity); + }, + }; + viewer.engine = createCesiumEngine(viewer); + return viewer; +} + +for (const [name, act, check] of [ + ['north-up', resetCameraNorth, (frame) => frame.heading], + ['tilt', toggleCameraTilt, (frame) => frame.pitch + Math.PI / 2], +]) { + test(`${name} keeps an engine follow that carries a native handle (Navara aircraft)`, () => { + const engine = createFakeEngine({ kind: 'navara' }); + const handle = followAircraft(engine); + engine.fake.frame(); + assert.ok(act(engine), `${name} applied`); + engine.fake.frame(); + assert.equal(engine.camera.following(), handle, `${name} keeps the follow`); + handle.stop(); + engine.destroy(); + }); + + test(`${name} keeps an engine follow on Cesium (aircraft)`, () => { + const viewer = cesiumViewer(); + const { engine } = viewer; + const handle = followAircraft(engine); + viewer.scene.preUpdate.raiseEvent(); + assert.ok(act(engine, viewer), `${name} applied`); + viewer.scene.preUpdate.raiseEvent(); + assert.equal(engine.camera.following(), handle, `${name} keeps the follow`); + const frame = readCameraTargetFrame(engine, viewer); + assert.ok(Math.abs(check(frame)) < 0.02, `${name} reached the camera`); + handle.stop(); + engine.destroy(); + }); +} diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index e1f03044a..2920cd12c 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -206,7 +206,7 @@ export class CockpitViewController { this.scratchAnchorCartographic = geo.cartographic.create(); this.scratchCameraCartographic = geo.cartographic.create(); this.scratchTargetCartographic = geo.cartographic.create(); - this._listenerRemovers = []; + this._listenerRemovers = [onLocaleChange(() => this.relocalize())]; // Camera mutations belong before scene update/culling. Changing the camera // from preRender makes 3D Tiles discover a new view after traversal and can diff --git a/src/ui/cockpitController.test.mjs b/src/ui/cockpitController.test.mjs index b73c9f679..a7279e972 100644 --- a/src/ui/cockpitController.test.mjs +++ b/src/ui/cockpitController.test.mjs @@ -306,3 +306,23 @@ test('the cockpit eye stays above the engine surface when the aircraft reads bel env.restore(); } }); + +test('locale changes relocalize the cockpit until it is disposed', async () => { + const { getLocale, setLocale } = await import('../i18n/index.js'); + const locale = getLocale(); + const env = environment(); + const owner = new CockpitViewController(env.engine, { services: {} }); + let updates = 0; + owner.relocalize = () => updates++; + try { + setLocale(locale === 'de' ? 'en' : 'de'); + assert.equal(updates, 1); + owner.dispose(); + setLocale(locale); + assert.equal(updates, 1); + } finally { + owner.dispose(); + setLocale(locale); + env.restore(); + } +}); diff --git a/src/ui/commandDock.test.mjs b/src/ui/commandDock.test.mjs index c70007a17..9cf5e4a58 100644 --- a/src/ui/commandDock.test.mjs +++ b/src/ui/commandDock.test.mjs @@ -486,3 +486,36 @@ test('the search toggle opens the field, and a lookup marks it', async (t) => { assert.equal(field.classList.contains('searching'), false); assert.equal(field.classList.contains('expanded'), false); }); + +test('the engine picker stays focusable and announces an engine switch', async (t) => { + const { $, pinia } = fixture(t); + const display = useDisplayStore(pinia); + const selected = []; + const select = (kind) => selected.push(kind); + display.presentGlobeEngine({ kind: 'cesium', busy: false, select }); + await nextTick(); + const picker = $('#globe-engine'); + picker.focus(); + display.presentGlobeEngine({ kind: 'cesium', busy: true, select }); + await nextTick(); + assert.equal(picker.disabled, false); + assert.equal(document.activeElement, picker); + assert.equal(picker.getAttribute('aria-busy'), 'true'); + assert.equal(picker.getAttribute('aria-disabled'), 'true'); + assert.equal( + $('#globe-engine-status').textContent, + 'Initializing systems...', + ); + picker.value = 'navara'; + picker.dispatchEvent(new Event('change', { bubbles: true })); + assert.deepEqual(selected, []); + await nextTick(); + assert.equal(picker.value, 'cesium', 'a refused pick is put back'); + display.presentGlobeEngine({ kind: 'navara', busy: false, select }); + await nextTick(); + assert.equal(document.activeElement, picker); + assert.equal(picker.value, 'navara'); + assert.ok(picker.classList.contains('gev-field')); + assert.equal($('#globe-engine-status').getAttribute('role'), 'status'); + assert.equal($('#globe-engine-status').textContent, 'Navara'); +}); diff --git a/src/ui/geoFileImport.js b/src/ui/geoFileImport.js index 1e5acb468..20cf6f83e 100644 --- a/src/ui/geoFileImport.js +++ b/src/ui/geoFileImport.js @@ -403,6 +403,7 @@ export function createGeoFileImport({ return; } await draw(item, attached, loaded); + if (!imports.includes(item)) undraw(item); } catch { if (destroyed || scene !== attached) return; setHint(`${item.name}: ${t('That file could not be read.')}`); diff --git a/src/ui/geoFileImportRedraw.test.mjs b/src/ui/geoFileImportRedraw.test.mjs new file mode 100644 index 000000000..4a6bf1c04 --- /dev/null +++ b/src/ui/geoFileImportRedraw.test.mjs @@ -0,0 +1,75 @@ +// A file removed while its drawing on the next engine is still pending +// (icon decode) must not leave that drawing on the globe. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createGeoFileImport } from './geoFileImport.js'; +import { emptyRecords } from '../data/geoImportRecords.js'; + +const el = () => ({ + classList: { toggle() {} }, + addEventListener() {}, + removeEventListener() {}, +}); +const engine = () => ({ + host: { container: el(), canvas: el() }, + camera: { moveEnd: { addEventListener: () => () => {} } }, + surface: { tilesLoaded: () => true }, + render: { request() {} }, +}); + +test('removing a file during its redraw destroys the late drawing', async () => { + const document = { + ...el(), + body: el(), + getElementById: (id) => (id === 'geo-import-button' ? el() : null), + }; + let actions = null; + const live = new Set(); + let gate = null; + const drawFile = async () => { + if (gate) await gate.promise; // the icon decode of the redraw + const drawing = { + report: { drawn: 1, undrawn: 0 }, + setShow() {}, + refreshGround: async () => {}, + footprint: () => [], + destroy: () => live.delete(drawing), + }; + live.add(drawing); + return drawing; + }; + const tool = createGeoFileImport({ + document, + drawFile, + load: async (f) => ({ + records: emptyRecords(), + name: f.name, + format: 'kmz', + entityCount: 1, + removed: 0, + revoke() {}, + }), + flyToSource() {}, + connect: (next) => ((actions = next), () => {}), + }); + tool.attachScene({ engine: engine() }); + await tool.importFiles([{ name: 'a.kmz', size: 1 }]); + const [{ id }] = tool.list(); + assert.equal(live.size, 1); + + // Engine switch: the drawing leaves; the next engine draws the file again. + tool.detachScene(); + assert.equal(live.size, 0); + let release; + gate = { promise: new Promise((r) => (release = r)) }; + tool.attachScene({ engine: engine() }); + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); + // The user removes the row while the icon still decodes. + assert.equal(actions.removeGeoImport(id), true); + assert.equal(tool.list().length, 0); + release(); + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); + assert.equal(live.size, 0, 'the removed file left a drawing on the globe'); + tool.destroy(); + assert.equal(live.size, 0, 'and destroy() cannot release it either'); +}); diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 80e62147a..579b39df1 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -7,12 +7,10 @@ import { LocationControls } from './location.js'; /** * Own destination selection, lookup, orbit and world-jump lifetime. The camera * is the globe `engine`'s, and a world jump stops its follow - * (`engine.camera.following()`); `viewer` is optional and only clears a - * Cesium tracked entity the engine does not report. + * (`engine.camera.following()`). */ export class LocationNavigation { constructor({ - viewer = null, engine = null, placeSearch, services, @@ -23,7 +21,7 @@ export class LocationNavigation { }) { Object.assign( this, - { viewer, engine, placeSearch, services, navigation, readCockpit }, + { engine, placeSearch, services, navigation, readCockpit }, elements, operations, ); @@ -405,7 +403,6 @@ export class LocationNavigation { controls?.closeSearch(); } - /** Revoke callbacks and settle owned camera work before the viewer is released. */ /** * Leave the globe (an engine switch): its reset flight, world-jump * transition and orbit end; the search, its text and the readout stay. @@ -420,14 +417,12 @@ export class LocationNavigation { this._stopOrbit(); } this.orbitController?.attachEngine(null); - this.viewer = null; this.engine = null; } /** Navigate on another globe (an engine switch). */ - attachScene({ viewer = null, engine = null } = {}) { + attachScene({ engine = null } = {}) { if (this._disposed) return; - this.viewer = viewer; this.engine = engine; this.orbitController?.attachEngine(engine); } diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 4acf9ee33..503da36a0 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -9,12 +9,10 @@ import { /** * Own camera authority generations, pending search UI and tracking handoff. - * The camera is the globe `engine`'s. `viewer` is optional and only clears - * Cesium's tracked entity. + * The camera and tracked subject belong to the globe `engine`. */ export class NavigationController { constructor({ - viewer = null, engine = null, tracking, settleSearch = () => {}, @@ -29,7 +27,6 @@ export class NavigationController { cancelOrientation = () => {}, }) { Object.assign(this, { - viewer, engine, tracking, settleSearch, @@ -234,7 +231,7 @@ export class NavigationController { * flight cannot land on the next one. `retainScene` keeps the selection a * handoff carries across. */ - setScene({ viewer = null, engine = null } = {}, { retainScene = true } = {}) { + setScene({ engine = null } = {}, { retainScene = true } = {}) { if (this._disposed) return; if (this.engine && engine !== this.engine) { cancelCameraArrival(this.engine); @@ -243,7 +240,6 @@ export class NavigationController { clearSearchedLocation: false, }); } - this.viewer = viewer; this.engine = engine; } /** Subscribe to ownership changes without claiming the camera or exposing mutable state. */ diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 4f7436e51..7ec7a485c 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -17,6 +17,7 @@ export function layersNotOnThisEngine(results) { import { stampInitialShareGesture } from '../navigationPolicy.js'; import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; import { t, msg, formatDate } from '../i18n/index.js'; +import { localizeLayerName } from './layerNames.js'; import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js'; import { UiLifetime } from './uiLifetime.js'; @@ -411,9 +412,11 @@ export class ShareRestoration { if (this._disposed || !Array.isArray(results)) return; const names = layersNotOnThisEngine(results); if (!names.length) return; - this.showStatus('NOT ON THIS ENGINE', { + this.showStatus(t('NOT ON THIS ENGINE'), { state: 'retry', - detail: `Kept in the link: ${names.join(', ')}`, + detail: t('Kept in the link: {layers}', { + layers: names.map(localizeLayerName).join(', '), + }), // Long enough to read a list of layer names; the engine's own // persistent notice comes back afterwards (ShellFeedback). dwellMs: NOT_ON_THIS_ENGINE_DWELL_MS, diff --git a/src/ui/shellScene.js b/src/ui/shellScene.js index 57912eb5b..b5b285332 100644 --- a/src/ui/shellScene.js +++ b/src/ui/shellScene.js @@ -333,8 +333,8 @@ export class ShellScene extends ShellFacade { militaryAwarenessLayer, rocketLaunchesLayer, }; - this._navigation.setScene({ viewer, engine }); - this._locationNavigation.attachScene({ viewer, engine }); + this._navigation.setScene({ engine }); + this._locationNavigation.attachScene({ engine }); this.hud.attachEngine(engine, { viewer }); this.shareLinkManager.attachEngine(engine); this._displayBindings.attachEngine(engine); diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 91115e206..270a4048d 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -795,6 +795,22 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label { outline: 2px solid currentColor; } +/* While a switch runs the picker keeps focus (aria-disabled, not disabled). */ +#globe-engine[aria-busy='true'] { + opacity: var(--gev-field-disabled-opacity, 0.45); + cursor: progress; +} + +/* Announces the switch to screen readers only. */ +#globe-engine-status { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + .map-source-section { display: grid; gap: 6px; diff --git a/src/ui/views/CommandDockView.vue b/src/ui/views/CommandDockView.vue index e06832b22..eb4d6ac47 100644 --- a/src/ui/views/CommandDockView.vue +++ b/src/ui/views/CommandDockView.vue @@ -3,6 +3,7 @@ import { storeToRefs } from 'pinia'; import { msg, t } from '../../i18n/index.js'; import { vPanelState } from '../rail/panelState.js'; import UiButton from '../kit/UiButton.vue'; +import UiSelect from '../kit/UiSelect.js'; import { vClassToggle } from '../rail/classToggle.js'; import { useDisplayStore } from '../stores/display.js'; import { computed } from 'vue'; @@ -18,6 +19,8 @@ import VoiceControl from './panels/VoiceControl.vue'; // button calls the Display controls' owner, a chip the map source controls. const display = useDisplayStore(); const { style, mapSource, mapSourceControls, globeEngine } = storeToRefs(display); +// Product names, as the picker's options show them. +const ENGINE_NAMES = { cesium: 'Cesium', navara: 'Navara', 'navara-hd': 'Navara HD' }; // LocationControls publishes the location tray; pills call the controls, // and the search field keeps their listeners. const { view: loc, controls: locationControls } = storeToRefs(useLocationStore()); @@ -115,13 +118,14 @@ function selectMapSource(chip) {
+ {{ globeEngine.busy ? t('Initializing systems...') : ENGINE_NAMES[globeEngine.kind] }}
diff --git a/src/ui/wildlifeSightingCard.js b/src/ui/wildlifeSightingCard.js index d0b642e6e..5c9544bc7 100644 --- a/src/ui/wildlifeSightingCard.js +++ b/src/ui/wildlifeSightingCard.js @@ -192,7 +192,7 @@ export function createWildlifeSightingCard({ if (fresh) panForId = null; nextTick(() => { if (destroyed || !selected.value) return; - position({ allowPan: fresh || metadata?.explicitSelection === true }); + position({ allowPan: metadata?.explicitSelection === true }); if (moving) startFrameUpdates(); // Focus the container so keyboard navigation starts inside the card. if (fresh) diff --git a/src/ui/wildlifeSightingCard.test.mjs b/src/ui/wildlifeSightingCard.test.mjs index 14c1f5049..3ce55af11 100644 --- a/src/ui/wildlifeSightingCard.test.mjs +++ b/src/ui/wildlifeSightingCard.test.mjs @@ -808,3 +808,31 @@ test('newly mounted chrome repositions the open card without camera events or id 'closing disconnects structural observation', ); }); + +test('remounting a selected sighting does not pan the new engine', async (context) => { + const f = fixture(context); + await f.click(); + f.card.destroy(); + f.obstacle('.hud-top-left', rect(0, 0, 260, 160)); + const ui = createUiRuntime(); + let pans = 0; + const card = createWildlifeSightingCard({ + layer: f.layer, + ui, + engine: f.engine, + documentRef: document, + windowRef: f.windowRef, + now: () => NOW, + project: () => ({ x: 100, y: 100 }), + pan: () => { + pans++; + return true; + }, + }); + context.after(() => { + card.destroy(); + ui.destroy(); + }); + await settle(); + assert.equal(pans, 0); +}); diff --git a/src/voice/areaActions.js b/src/voice/areaActions.js index 1aef6a8d8..594a3e8f0 100644 --- a/src/voice/areaActions.js +++ b/src/voice/areaActions.js @@ -162,15 +162,12 @@ export function createVoiceAreas({ findRegion = findNaturalRegion, findAdminCandidates = findBundledAdminCandidates, aroundOutlineMs = AROUND_OUTLINE_MS, -} = {}) { - // An area an outline on the map still refers to is never evicted: its - // counting geometry must outlive the display copy drawn from it. - const store = createAreaStore({ + store = createAreaStore({ isPinned: (areaId) => typeof annotations?.list === 'function' && annotations.list().some((anno) => anno?.areaId === areaId), - }); - + }), +} = {}) { /** The ground at the centre of the view, else the camera's position. */ function viewCenter() { const picked = annotationResolver?.pickWorldFromScreen?.(engine, 0.5, 0.5); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 56b068f58..eef8158ac 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -374,7 +374,7 @@ export function createGevActionRunner({ cameraHandoffs += 1; }) ?? null; let analystEngine; - // Area handles live for this runner (the page): resolve_area, the + // Area handles live for the page session: resolve_area, the // analyst's region/area/drawn scopes, find_imagery and osm_query all read // the same store. const voiceAreas = createVoiceAreas({ @@ -3314,12 +3314,14 @@ export async function getBasemapLabelContext( const latitude = Number(geo.math.toDegrees(viewTarget.latitude).toFixed(6)); const longitude = Number(geo.math.toDegrees(viewTarget.longitude).toFixed(6)); + const pose = engine.camera.pose(); const view = placeLookupView({ latitude, longitude, rangeM: viewTargetRangeM(engine, viewTarget, cameraHeightM), - fovRad: engine.camera.pose().fovy, - aspectRatio: engine.camera.pose().aspectRatio, + fovRad: + 2 * Math.atan(Math.tan(pose.fovy / 2) * Math.max(1, pose.aspectRatio)), + aspectRatio: pose.aspectRatio, tier: basemapLookupTier(cameraHeightM), }); if ( diff --git a/src/voice/gevRealtime.js b/src/voice/gevRealtime.js index 64df161c6..59f1cdd3c 100644 --- a/src/voice/gevRealtime.js +++ b/src/voice/gevRealtime.js @@ -6,6 +6,7 @@ import { cameraPoseKey, createPointerTracker } from './pointerContext.js'; import { createPointerPicker } from './pointerPick.js'; import { createReferentRegistry } from './referents.js'; import { createSceneRunner } from './sceneRunner.js'; +import { createAreaStore } from '../data/areaStore.js'; export * from './realtimeController.js'; const ACTION_NAMES = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name)); @@ -45,6 +46,14 @@ export function withToolCatalog(runner, loadCatalog) { */ export function createSceneVoice(options) { const scene = { engine: null }; + let areaAnnotations = null; + const areaStore = + options.areaOptions?.store ?? + createAreaStore({ + isPinned: (areaId) => + areaAnnotations?.list?.().some((anno) => anno?.areaId === areaId) ?? + false, + }); const cameraKey = () => cameraPoseKey(scene.engine); let pick = null; // Point-and-ask: the tracker and referents are shared by the runner (which @@ -74,6 +83,7 @@ export function createSceneVoice(options) { deixis, analystMemory, layerEnabledAt, + areaOptions: { ...options.areaOptions, store: areaStore }, readVoicePanelOperation: () => commands?.captureVoicePanelOperation?.(), }), @@ -141,6 +151,7 @@ export function createSceneVoice(options) { if (lifetime?.aborted) return; detachScene(); const engine = attached.engine ?? null; + areaAnnotations = attached.annotations ?? null; scene.engine = engine; shell = attached.styleManager ?? null; pick = createPointerPicker({ diff --git a/src/voice/hudPlaceLabels.test.mjs b/src/voice/hudPlaceLabels.test.mjs index 7e4b94ec5..6f94085da 100644 --- a/src/voice/hudPlaceLabels.test.mjs +++ b/src/voice/hudPlaceLabels.test.mjs @@ -368,3 +368,17 @@ test('cached-only lookups read every cached viewport sample and never request', assert.ok(b.placeLabels.includes(cachedLabel)); assert.deepEqual(service.calls, before); }); + +test('basemap lookup measures the horizontal camera span', async () => { + const { viewer } = createViewer({ + latitude: 30, + longitude: -97, + heightM: 1000, + }); + const result = await getBasemapLabelContext(viewer, createService(), { + cachedOnly: true, + }); + assert.ok( + Math.abs(result.view.spanM - 2 * 1000 * Math.tan(Math.PI / 6) * 1.5) < 1, + ); +}); diff --git a/src/voice/sceneVoice.test.mjs b/src/voice/sceneVoice.test.mjs index e2015bc1d..57d1493bd 100644 --- a/src/voice/sceneVoice.test.mjs +++ b/src/voice/sceneVoice.test.mjs @@ -115,3 +115,33 @@ test('annotation outline events reach the session from the attached engine only' f.restore(); } }); + +test('area handles outlive an engine attachment', () => { + const f = fixture(); + try { + f.voice.attachScene({ engine: fakeEngine('cesium') }); + const store = f.built[0].areaOptions?.store; + assert.ok(store); + const area = store.put({ + name: 'Area', + source: 'drawn', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + ], + }, + }); + f.voice.detachScene(); + f.voice.attachScene({ engine: fakeEngine('navara') }); + assert.equal(f.built[1].areaOptions.store.get(area.areaId), area); + } finally { + f.lifetime.abort(); + f.restore(); + } +});