From 35e70ec280474852c6e21a8c2b180fef0ac4c486 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 7 Nov 2026 02:34:34 +0400 Subject: [PATCH] Draw the aircraft family, Wildlife Sightings and the Cockpit through the contract The layers that still had one Cesium and one Navara implementation, or drew on Cesium only, now draw through engine.movers, engine.lines and engine.camera on both engines, and application code holds the engine instead of guessing it from a viewer. - Aircraft family: flights, military and local ADS-B share one display (src/layers/aircraft/render/display.js). The Cesium and Navara renderers are gone. Where they differed, the display follows Cesium: - attach and visibility tracking, live disableDepthTestDistance changes, the tracked glyph's distance scale, model ids that change, local ADS-B markers hidden until shown and its UAT ring as a point; - the trail head's hidden alpha is 0.45, the contract's depthFailAlpha scaled by the head's 0.9. The tracked glyph stays over the fleet, as on Navara. The movers contract reports readiness per model and reads model axes as the glTF's own on every engine. Cesium's follow takes any native target through a proxy entity in the ENU frame and mirrors its readout fields. - Wildlife Sightings draws badges and the density glow as sprite sets and its privacy cells as line outlines (no fill yet). It takes clicks from engine.input and its card anchors to the engine's canvas and camera, so the layer is in both engines' catalogs again and a switch keeps the session's layer set. - Cockpit: cockpitHost takes the engine only. The ridden aircraft is the engine follow's target, hidden through its sprite; only the Cesium Viewer's stock double-click is still routed around. - About forty camera, navigation, share-link, HUD, voice, detection and layer-lifecycle callers take the engine. isGlobeEngine and engineFromViewer calls outside src/globe fall from 51 to 14. The coupling baseline drops accordingly: layer renderer lines from 5239/12331 to 4546/11106 (Cesium/Navara), `viewer` lines from 2424 to 2209, Cesium-importing files from 69 to 66. --- changelog.d/1555-navara-globe-engine.md | 5 +- docs/CURRENT-STATE.md | 24 +- scripts/engine-coupling-baseline.json | 14 +- scripts/format-scope.json | 22 +- scripts/package-boundaries.json | 33 +- scripts/qa-traffic-oblique.mjs | 2 +- scripts/qa-zoom-out-guard.mjs | 4 +- src/app/constructCatalog.test.mjs | 12 +- src/app/controls.test.mjs | 5 +- src/app/globeEngine.js | 4 +- src/app/layers/bikeshare.js | 2 +- src/app/layers/flights.js | 12 +- src/app/layers/localAdsb.js | 15 +- src/app/layers/militaryFlights.js | 13 +- src/app/layers/renderers.js | 15 +- src/app/layers/renderers.test.mjs | 43 +- src/app/layers/transit.js | 2 +- src/app/layers/wildlifeSightings.js | 7 +- src/app/loadCesium.test.mjs | 2 +- src/app/navaraLayers.test.mjs | 1 + src/app/navaraMode.js | 16 +- src/app/operations.js | 4 +- src/camera.js | 7 +- src/cameraGroundGuard.js | 7 +- src/cameraGroundGuard.test.mjs | 16 +- src/cameraHandoff.test.mjs | 2 +- src/cameraTilt.test.mjs | 8 +- src/cameraVerbs.js | 5 +- src/celestialRing.js | 18 +- src/celestialRing/view.js | 3 - src/cockpitMarkup.test.mjs | 19 +- src/data/cameraArrival.js | 21 +- src/data/cameraArrival.test.mjs | 14 +- src/data/cameraSensitivity.js | 17 +- src/data/cameraSensitivity.test.mjs | 12 +- src/data/cockpitDetectionSubjects.test.mjs | 3 + src/data/cockpitModels.test.mjs | 21 +- src/data/detection.js | 24 +- src/data/detectionHost.test.mjs | 30 +- src/data/flights.test.mjs | 11 +- src/data/lifecycle.js | 15 +- src/data/militaryAwareness.test.mjs | 9 + src/data/militaryFlights.edgeCases.test.mjs | 4 + src/data/militaryFlights.test.mjs | 9 +- src/data/nearbyModelHandoff.test.mjs | 3 + src/data/spriteOrder.test.mjs | 14 +- src/data/tr3bRegistry.test.mjs | 7 +- src/data/trackedModelRegime.test.mjs | 3 + src/data/trackedReadout.js | 30 +- src/data/trackedReadout.test.mjs | 5 +- src/globe/cesium/follow.js | 32 +- src/globe/cesium/follow.test.mjs | 32 + src/globe/cesium/movers.js | 28 +- src/globe/cesium/movers.test.mjs | 20 + src/globe/contract.js | 17 +- src/globe/movers.js | 7 +- src/globe/testing/contractSuite.mjs | 13 + src/hud.js | 3 +- src/layers/aircraft/render/cesium.js | 425 --------- src/layers/aircraft/render/cesium.test.mjs | 324 ------- src/layers/aircraft/render/contract.js | 46 +- .../aircraft/render/{navara.js => display.js} | 336 ++++--- src/layers/aircraft/render/display.test.mjs | 854 +++++++++++++++++ src/layers/aircraft/render/navara.test.mjs | 357 -------- .../bikeshare/render/cesium/lifecycle.js | 9 +- src/layers/flights/ownership.test.mjs | 7 +- src/layers/flights/render/cesium.js | 23 - src/layers/flights/render/display.js | 14 + src/layers/flights/render/navara.js | 11 - src/layers/flights/renderer.test.mjs | 91 +- src/layers/flights/state.js | 6 +- src/layers/flights/testing.js | 13 +- .../installations/render/cesium/lifecycle.js | 9 +- src/layers/localAdsb/edgeCases.test.mjs | 60 +- src/layers/localAdsb/index.js | 25 +- src/layers/localAdsb/index.test.mjs | 472 ++++------ src/layers/localAdsb/models.js | 3 +- src/layers/localAdsb/render/cesium.js | 217 ----- .../render/{navara.js => display.js} | 102 +-- src/layers/localAdsb/renderer.test.mjs | 31 +- src/layers/military/ownership.test.mjs | 11 +- src/layers/military/render/cesium.js | 24 - src/layers/military/render/display.js | 15 + src/layers/military/render/navara.js | 11 - src/layers/military/renderer.test.mjs | 9 +- src/layers/military/state.js | 6 +- src/layers/military/testing.js | 17 +- src/layers/shared/render/moverBank.js | 128 +++ src/layers/shared/render/navaraMoverBank.js | 129 +-- src/layers/transit/render/cesium.js | 6 +- .../wildlifeSightings/acquisition.test.mjs | 63 +- .../wildlifeSightings/billboardModel.js | 24 +- src/layers/wildlifeSightings/cards.test.mjs | 15 +- src/layers/wildlifeSightings/controls.js | 3 +- src/layers/wildlifeSightings/density.js | 227 +++-- src/layers/wildlifeSightings/density.test.mjs | 309 ++++--- src/layers/wildlifeSightings/display.js | 216 +++++ src/layers/wildlifeSightings/docs.test.mjs | 4 +- src/layers/wildlifeSightings/gridViewport.js | 6 +- .../wildlifeSightings/gridViewport.test.mjs | 24 +- src/layers/wildlifeSightings/index.js | 24 +- src/layers/wildlifeSightings/lifecycle.js | 154 ++-- .../wildlifeSightings/ownership.test.mjs | 867 +++++++++--------- src/layers/wildlifeSightings/policy.js | 3 - .../wildlifeSightings/render.perf.test.mjs | 4 +- .../renderPerformanceFixtures.mjs | 83 +- src/layers/wildlifeSightings/rendering.js | 371 +++----- .../wildlifeSightings/rendering.test.mjs | 460 +++++----- src/layers/wildlifeSightings/selection.js | 146 +-- .../wildlifeSightings/selection.test.mjs | 31 +- src/layers/wildlifeSightings/sprites.js | 49 +- src/layers/wildlifeSightings/sprites.test.mjs | 52 ++ src/layers/wildlifeSightings/viewport.js | 8 +- .../wildlifeSightings/viewport.test.mjs | 35 +- src/locations.js | 27 +- src/orbit.js | 8 +- .../worldOverlayAllocation.worker.mjs | 2 +- src/scenes/director.js | 7 +- src/services/meshFloorSampler.js | 7 +- src/sharelink.js | 8 +- src/testSupport/aircraftViewerRenderer.mjs | 256 ++++++ src/testSupport/cesiumMoversEngine.mjs | 82 ++ src/testSupport/cockpitEngine.mjs | 47 + src/testSupport/viewerEngine.mjs | 16 + src/ui/applicationShell.js | 20 +- src/ui/cameraOrientationControls.js | 67 +- src/ui/cameraOrientationControls.test.mjs | 75 +- src/ui/cockpitCamera.js | 2 +- src/ui/cockpitController.js | 17 +- src/ui/cockpitController.test.mjs | 104 ++- src/ui/cockpitCoordinator.js | 33 +- src/ui/cockpitEntryGate.test.mjs | 41 +- src/ui/cockpitHost.js | 46 +- src/ui/cockpitPresence.test.mjs | 31 +- src/ui/cockpitTrackingController.js | 13 +- src/ui/layerBindings.js | 2 +- src/ui/locationNavigation.js | 7 +- src/ui/navigationController.js | 5 +- src/ui/wildlifeSightingCard.js | 20 +- src/ui/wildlifeSightingCard.test.mjs | 121 +-- src/ui/wildlifeSightingPlacement.js | 84 +- src/voice/gevActions.js | 16 +- src/voice/gevActions.test.mjs | 40 +- src/voice/gevRealtime.js | 3 +- src/worldFocus.js | 6 +- 145 files changed, 4716 insertions(+), 4212 deletions(-) delete mode 100644 src/layers/aircraft/render/cesium.js delete mode 100644 src/layers/aircraft/render/cesium.test.mjs rename src/layers/aircraft/render/{navara.js => display.js} (71%) create mode 100644 src/layers/aircraft/render/display.test.mjs delete mode 100644 src/layers/aircraft/render/navara.test.mjs delete mode 100644 src/layers/flights/render/cesium.js create mode 100644 src/layers/flights/render/display.js delete mode 100644 src/layers/flights/render/navara.js delete mode 100644 src/layers/localAdsb/render/cesium.js rename src/layers/localAdsb/render/{navara.js => display.js} (67%) delete mode 100644 src/layers/military/render/cesium.js create mode 100644 src/layers/military/render/display.js delete mode 100644 src/layers/military/render/navara.js create mode 100644 src/layers/shared/render/moverBank.js create mode 100644 src/layers/wildlifeSightings/display.js create mode 100644 src/testSupport/aircraftViewerRenderer.mjs create mode 100644 src/testSupport/cesiumMoversEngine.mjs create mode 100644 src/testSupport/cockpitEngine.mjs diff --git a/changelog.d/1555-navara-globe-engine.md b/changelog.d/1555-navara-globe-engine.md index 1164e8b42..246f4bb42 100644 --- a/changelog.d/1555-navara-globe-engine.md +++ b/changelog.d/1555-navara-globe-engine.md @@ -3,9 +3,8 @@ `?engine=navara`. Navara draws at CSS pixels like Cesium; Navara HD (`?engine=navara-hd`) draws at the screen's full resolution and needs about 1 GiB more on a Retina screen. Layers keep their data across a switch and - draw on both engines, except Wildlife Sightings, which is Cesium-only for - now; Cesium loads only when a Cesium globe or a Cesium-only feature needs - it (German, @gekh, #1555). + draw on both engines; Cesium loads only when a Cesium globe or a + Cesium-only feature needs it (German, @gekh, #1555). - Keep the cockpit camera steady near the ground: it no longer jumps up and down when an aircraft's reported altitude sits below the globe's terrain, on either engine (German, @gekh, #1555). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f12bfd1cd..4366d4904 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -206,9 +206,13 @@ one application, and only the globe-bound parts move to the next engine. (`scripts/qa-strict-parked-release.mjs`). - **Renderers per engine.** Each layer keeps its model and display policy in engine-neutral files and draws through `render/cesium*.js` or - `render/navara*.js`. Cesium renderers are listed in - `src/app/layers/renderers.js` (`CESIUM_LAYER_RENDERERS`), Navara ones are - loaded by `registerNavaraLayerRenderers` in `src/app/navaraMode.js`. Navara + `render/navara*.js`, or through one `render/display.js` that runs on every + engine over the globe contract: the aircraft family (flights, military and + local ADS-B) draws through `src/layers/aircraft/render/display.js`. Cesium + renderers are listed in `src/app/layers/renderers.js` + (`CESIUM_LAYER_RENDERERS`), Navara ones are loaded by + `registerNavaraLayerRenderers` in `src/app/navaraMode.js`; both register the + same display factory for a layer that has one. Navara ground effects (heat, pulses, trails, pads, drapes) are declared with `defineGroundEffect` (`src/layers/shared/render/navaraGroundEffect.js`); their insert order is pinned by `navaraGroundOrder.test.mjs`. @@ -288,17 +292,23 @@ snapshot, including one whose rows all fall outside coverage or the species checks, is a fresh success that clears earlier rows; only a malformed or failed upstream reply serves stale and starts a retry cooldown. WHALES accepts only recognized cetaceans; other identified marine mammals appear under ALL or -MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia +MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells, drawn as +outlines (`engine.lines`), with no fill yet; Acartia positions are rounded to 0.01° on the server. Acartia comments are never retained. Observer identity is retained only for photo attribution. -`/api/inaturalist/grid` supplies a dedicated, nonpickable billboard density glow, +The layer draws on both engines through one display +(`src/layers/wildlifeSightings/display.js`): taxon badges and the density glow +are `engine.movers` sprite sets. + +`/api/inaturalist/grid` supplies a dedicated, nonpickable sprite density glow, with a view-relative density ramp from cool teal to warm amber. Overlapping Gaussian splats span four logical cell widths; mono and CRT styles follow map and cockpit vision changes. Empty snapshots paint nothing. Full footprints stay inside the globe limb with a 5% radius margin; opacity fades from full at -3,000 km to zero at 8,000 km camera altitude. Culling and cached coordinates -add no per-frame work and leave sighting counts intact. +3,000 km to zero at 8,000 km camera altitude. Splats are sized in metres on +the CPU, only on frames where the camera moved; culling and cached coordinates +leave sighting counts intact. Only real sampled observation rows produce taxon badges at their public positions. The badges use 28 px screen buckets, a 22 px overlap check and a diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index e9af437a7..11138164f 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -1,16 +1,16 @@ { "metrics": { - "cesiumLayerRendererLines": 5239, - "navaraLayerRendererLines": 12331, - "cesiumImportFiles": 69, - "viewerLines": 2424, + "cesiumLayerRendererLines": 4546, + "navaraLayerRendererLines": 11106, + "cesiumImportFiles": 66, + "viewerLines": 2209, "navaraKindChecks": 17, - "engineSniffCalls": 51, + "engineSniffCalls": 14, "engineExtensionReads": 27, "groundEffectPasses": 9, "cesiumSdkLoads": 6, "screenSpaceHandlerFiles": 20, - "engineNameLines": 175 + "engineNameLines": 162 }, - "measuredAt": "german/navara merged with origin/main 8da8c05e, 2026-11-07" + "measuredAt": "after the aircraft, Wildlife and Cockpit displays and engine-only app code, 2026-11-07" } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index fbebe97f9..81d4f41f0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1213,22 +1213,21 @@ "src/data/headingDegrees.test.mjs", "src/data/distanceFormat.test.mjs", "src/sources/adsbFields.test.mjs", - "src/layers/flights/render/cesium.js", - "src/layers/flights/render/navara.js", - "src/layers/aircraft/render/navara.test.mjs", - "src/layers/aircraft/render/navara.js", + "src/layers/flights/render/display.js", + "src/layers/aircraft/render/display.test.mjs", + "src/layers/aircraft/render/display.js", "src/layers/flights/renderer.test.mjs", "src/layers/military/renderer.test.mjs", "src/layers/military/policy.test.mjs", "src/testSupport/aircraftRecordingRenderer.mjs", - "src/layers/aircraft/render/cesium.test.mjs", - "src/layers/aircraft/render/cesium.js", + "src/testSupport/aircraftViewerRenderer.mjs", + "src/testSupport/cesiumMoversEngine.mjs", + "src/testSupport/cockpitEngine.mjs", "src/layers/aircraft/render/contract.js", "src/layers/shared/color.test.mjs", "src/layers/shared/color.js", "src/layers/shared/viewRectangle.js", - "src/layers/military/render/cesium.js", - "src/layers/military/render/navara.js", + "src/layers/military/render/display.js", "src/layers/aircraft/qaDrawn.js", "src/app/navaraLayers.test.mjs", "src/layers/vessels/icons.js", @@ -1240,6 +1239,7 @@ "src/layers/shared/render/engineView.js", "src/layers/shared/render/navaraGroundEffect.js", "src/layers/shared/render/navaraMoverBank.js", + "src/layers/shared/render/moverBank.js", "src/layers/shared/render/imageAtlas.js", "src/layers/installations/render/cesium/lifecycle.js", "src/layers/installations/render/cesium/rendering.js", @@ -1317,8 +1317,7 @@ "src/layers/transit/render/navaraTrail.js", "src/layers/transit/render/navara.test.mjs", "scripts/fixtures/w2/transit.mjs", - "src/layers/localAdsb/render/cesium.js", - "src/layers/localAdsb/render/navara.js", + "src/layers/localAdsb/render/display.js", "src/layers/localAdsb/renderer.test.mjs", "build/navaraAssets.js", "scripts/qa-engine-switch.mjs", @@ -1624,5 +1623,6 @@ "scripts/engineCoupling.test.mjs", "scripts/qa-contract-suite.mjs", "src/globe/cesium/doubleClick.js", - "scripts/engine-coupling-baseline.json" + "scripts/engine-coupling-baseline.json", + "src/layers/wildlifeSightings/display.js" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4aecb5694..99bf2c649 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3483,18 +3483,14 @@ "src/globe/navara/followGestures.js", "src/app/layers/directory.js", "src/app/layers/renderers.js", - "src/layers/aircraft/render/cesium.js", - "src/layers/flights/render/cesium.js", - "src/layers/military/render/cesium.js", + "src/layers/aircraft/render/display.js", + "src/layers/flights/render/display.js", + "src/layers/military/render/display.js", "src/data/selectionDispatcher.js", "src/layers/shared/color.js", - "src/layers/aircraft/render/navara.js", - "src/layers/flights/render/navara.js", - "src/layers/military/render/navara.js", "src/layers/aircraft/qaDrawn.js", - "src/layers/localAdsb/render/cesium.js", + "src/layers/localAdsb/render/display.js", "src/layers/vessels/render/cesium.js", - "src/layers/localAdsb/render/navara.js", "src/layers/vessels/render/navara.js", "src/layers/launches/render/navara.js", "src/layers/launches/render/navaraPadGround.js", @@ -3550,6 +3546,7 @@ "src/layers/transit/render/navara.js", "src/layers/transit/render/navaraSprites.js", "src/layers/shared/render/navaraMoverBank.js", + "src/layers/shared/render/moverBank.js", "src/layers/transit/render/navaraView.js", "src/layers/transit/render/navaraTrail.js", "src/layers/recentImagery/render/navara.js", @@ -3619,7 +3616,8 @@ "src/overlays/worldOverlayChrome.js", "src/layers/wildlifeSightings/photoText.js", "src/layers/wildlifeSightings/performance.js", - "src/globe/cesium/doubleClick.js" + "src/globe/cesium/doubleClick.js", + "src/layers/wildlifeSightings/display.js" ], "external": [ "hls.js", @@ -4303,13 +4301,14 @@ "src/globe/cesium/follow.js", "src/app/layers/directory.js", "src/app/layers/renderers.js", - "src/layers/aircraft/render/cesium.js", + "src/layers/aircraft/render/display.js", "src/data/selectionDispatcher.js", "src/layers/shared/color.js", - "src/layers/flights/render/cesium.js", - "src/layers/military/render/cesium.js", + "src/layers/flights/render/display.js", + "src/layers/military/render/display.js", "src/layers/aircraft/qaDrawn.js", - "src/layers/localAdsb/render/cesium.js", + "src/layers/localAdsb/render/display.js", + "src/layers/shared/render/moverBank.js", "src/layers/vessels/render/cesium.js", "src/celestialRing/render/cesium.js", "src/celestialRing/view.js", @@ -4352,7 +4351,8 @@ "src/layers/wildlifeSightings/sprites.js", "src/overlays/worldOverlayChrome.js", "src/layers/wildlifeSightings/photoText.js", - "src/layers/wildlifeSightings/performance.js" + "src/layers/wildlifeSightings/performance.js", + "src/layers/wildlifeSightings/display.js" ], "external": [ "hls.js", @@ -5647,7 +5647,10 @@ "src/geo/", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js", - "src/globe/cesium/credits.js" + "src/globe/cesium/credits.js", + "src/layers/wildlifeSightings/display.js", + "src/data/spriteOrder.js", + "src/layers/shared/color.js" ], "external": ["cesium"] }, diff --git a/scripts/qa-traffic-oblique.mjs b/scripts/qa-traffic-oblique.mjs index 6ea1c953d..cdc2b9265 100644 --- a/scripts/qa-traffic-oblique.mjs +++ b/scripts/qa-traffic-oblique.mjs @@ -226,7 +226,7 @@ try { await page.evaluate(async (v) => { const { flyToLandmark } = await import('/src/locations.js'); await new Promise((resolve) => - flyToLandmark(window.__godsEyeView.viewer, v.lat, v.lon, { + flyToLandmark(window.__godsEyeView.engine, v.lat, v.lon, { range: 1000, pitch: -15, heading: 0, diff --git a/scripts/qa-zoom-out-guard.mjs b/scripts/qa-zoom-out-guard.mjs index 770a4148d..f6a4aec84 100644 --- a/scripts/qa-zoom-out-guard.mjs +++ b/scripts/qa-zoom-out-guard.mjs @@ -39,7 +39,7 @@ try { const { maxPitchForHeight } = await import('/src/app/zoomOutGuard.js'); const { readCameraTargetFrame } = await import('/src/ui/cameraOrientationControls.js'); - const viewer = window.__godsEyeView.viewer; + const { viewer, engine } = window.__godsEyeView; viewer.camera.cancelFlight(); viewer.trackedEntity = undefined; // Explicit real renders make each gesture independent of host frame rate. @@ -51,7 +51,7 @@ try { const qa = { viewer, guardWrites: 0, - targetPitch: () => readCameraTargetFrame(viewer)?.pitch, + targetPitch: () => readCameraTargetFrame(engine, viewer)?.pitch, history: [], frame() { viewer.scene.requestRender(); diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 878ec2b14..f94ab895b 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -251,7 +251,7 @@ test('the catalog per engine: Cesium as before, Navara only what it can show', a assert.equal(subset.get('military'), undefined); }); -test('both production registries attach to the same session models, Cesium-only layers aside', async (t) => { +test('both production registries attach to the same 38 session models', async (t) => { const { registerNavaraLayerRenderers } = await import('./navaraMode.js'); const lifetime = new AbortController(); t.after(() => lifetime.abort()); @@ -265,18 +265,12 @@ test('both production registries attach to the same session models, Cesium-only await registerNavaraLayerRenderers(); const navara = createApplicationCatalog({ ...options, engineKind: 'navara' }); assert.equal(cesium.layers.length, 38); - // Wildlife Sightings still draws itself on Cesium: Navara leaves it out - // until it draws through the globe contract. - const cesiumOnly = new Set(['wildlife-sightings']); assert.deepEqual( navara.layers.map(({ id }) => id), - cesium.layers.map(({ id }) => id).filter((id) => !cesiumOnly.has(id)), + cesium.layers.map(({ id }) => id), ); + assert.deepEqual(navara.metadata, cesium.metadata); for (const layer of cesium.layers) { - if (cesiumOnly.has(layer.id)) { - assert.equal(navara.get(layer.id), undefined, layer.id); - continue; - } assert.equal(navara.get(layer.id), layer, layer.id); assert.equal(typeof layer.attachRenderer, 'function', layer.id); assert.equal(typeof layer.detachRenderer, 'function', layer.id); diff --git a/src/app/controls.test.mjs b/src/app/controls.test.mjs index 9a3500989..3fa26a5d2 100644 --- a/src/app/controls.test.mjs +++ b/src/app/controls.test.mjs @@ -31,10 +31,7 @@ test('the controls pass the scene engine to the shell', () => { test('the shell initializes the world overlay on its engine', () => { const shell = read('../ui/applicationShell.js'); - assert.match( - shell, - /\{\n\s+engine = engineFromViewer\(viewer\),\n\s+mapStackController = null,/, - ); + assert.match(shell, /\{\n\s+engine = null,\n\s+mapStackController = null,/); assert.match(shell, /initWorldOverlay\(engine\);/); assert.doesNotMatch(shell, /initWorldOverlay\(viewer\)/); }); diff --git a/src/app/globeEngine.js b/src/app/globeEngine.js index a90e0ffb7..7edb0153a 100644 --- a/src/app/globeEngine.js +++ b/src/app/globeEngine.js @@ -3,8 +3,8 @@ import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js'; /** * Build the page's one globe engine on the application viewer and tie its - * teardown to the scene lifetime; engineFromViewer(viewer) returns it while - * it lives. Call after the viewer's own cleanup is + * teardown to the scene lifetime; it stays registered for the viewer + * (engineFromViewer) while it lives. Call after the viewer's own cleanup is * deferred, so the engine is destroyed first. Render holds taken before the * render governor is installed apply when it is (src/renderGovernor.js). * @param {Cesium.Viewer} viewer diff --git a/src/app/layers/bikeshare.js b/src/app/layers/bikeshare.js index 42ec8f3c6..ee2a69308 100644 --- a/src/app/layers/bikeshare.js +++ b/src/app/layers/bikeshare.js @@ -13,7 +13,7 @@ export function createApplicationBikeshare({ source }) { rendererFactory: ({ engine, ...context }) => engine?.kind === 'navara' ? rendererFor('bikeshare', engine, context) - : createBikeshareCesiumRenderer(context), + : createBikeshareCesiumRenderer({ ...context, engine }), services: { render, sprites, picking, overlays }, }); } diff --git a/src/app/layers/flights.js b/src/app/layers/flights.js index c622fdffa..88a49c11b 100644 --- a/src/app/layers/flights.js +++ b/src/app/layers/flights.js @@ -4,7 +4,6 @@ import * as selection from '../../data/selectionDispatcher.js'; import * as aircraftPresentation from '../../data/tr3bRegistry.js'; import * as trackedCamera from '../../data/trackedCamera.js'; import { rendererFor } from './renderers.js'; -import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js'; import * as labels from '../../data/detectionDraw.js'; import * as geoid from '../../data/geoid.js'; import * as focus from '../../data/focusDeemphasis.js'; @@ -20,12 +19,10 @@ export function createApplicationFlights({ militaryRegistry, resolveAsset = (url) => `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, - // Test seam: the renderer factory (default: the engine's, from the registry). + // Test seam: the renderer factory (default: the engine's, from the + // registry: the flights display on every engine). renderer = { - create: ({ viewer, engine = null, adopt }) => - engine - ? rendererFor('flights', engine, { viewer, adopt }) - : createFlightsCesiumRenderer(null, { viewer, adopt }), + create: ({ engine = null } = {}) => rendererFor('flights', engine), }, }) { const { groundFloor, meshFloor, groundSnap } = surface; @@ -35,8 +32,7 @@ export function createApplicationFlights({ services: { picking, selection, - // The engine's flights renderer from the registry (render/cesium.js on - // Cesium); a bare viewer without an engine (unit tests) draws on Cesium. + // The engine's flights renderer from the registry (render/display.js). renderer, aircraftPresentation, camera: trackedCamera, diff --git a/src/app/layers/localAdsb.js b/src/app/layers/localAdsb.js index aed095062..38f831a3e 100644 --- a/src/app/layers/localAdsb.js +++ b/src/app/layers/localAdsb.js @@ -6,8 +6,6 @@ import * as context from '../../data/contextStore.js'; import * as picking from '../../data/pickRegistry.js'; import * as selection from '../../data/selectionDispatcher.js'; import { rendererFor } from './renderers.js'; -import { createLocalAdsbCesiumRenderer } from '../../layers/localAdsb/render/cesium.js'; -import * as trails from '../../data/trailRenderer.js'; import * as geoid from '../../data/geoid.js'; import { markDetectionSourcesChanged } from '../../data/detection.js'; import { refreshTrackedReadout } from '../../data/trackedReadout.js'; @@ -30,12 +28,10 @@ export function createApplicationLocalAdsb({ displayParams = null, resolveAsset = (url) => `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, - // Test seam: the renderer factory (default: the engine's, from the registry). + // Test seam: the renderer factory (default: the engine's, from the + // registry: the Local ADS-B display on every engine). renderer = { - create: ({ engine = null, ...options }) => - engine - ? rendererFor('local-adsb', engine, options) - : createLocalAdsbCesiumRenderer(null, options), + create: ({ engine = null } = {}) => rendererFor('local-adsb', engine), }, } = {}) { return createLocalAdsbLayer({ @@ -47,11 +43,8 @@ export function createApplicationLocalAdsb({ context, picking, selection, - // The engine's Local ADS-B renderer from the registry (render/cesium.js - // on Cesium); a bare viewer without an engine (unit tests) draws on - // Cesium. + // The engine's Local ADS-B renderer from the registry (render/display.js). renderer, - trails, geoid, groundSnap: surface?.groundSnap || null, enrichment, diff --git a/src/app/layers/militaryFlights.js b/src/app/layers/militaryFlights.js index d58e40459..4a23c7d45 100644 --- a/src/app/layers/militaryFlights.js +++ b/src/app/layers/militaryFlights.js @@ -4,7 +4,6 @@ import * as selection from '../../data/selectionDispatcher.js'; import * as aircraftPresentation from '../../data/tr3bRegistry.js'; import * as trackedCamera from '../../data/trackedCamera.js'; import { rendererFor } from './renderers.js'; -import { createMilitaryCesiumRenderer } from '../../layers/military/render/cesium.js'; import * as labels from '../../data/detectionDraw.js'; import * as geoid from '../../data/geoid.js'; import * as focus from '../../data/focusDeemphasis.js'; @@ -20,12 +19,10 @@ export function createApplicationMilitary({ militaryRegistry, resolveAsset = (url) => `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, - // Test seam: the renderer factory (default: the engine's, from the registry). + // Test seam: the renderer factory (default: the engine's, from the + // registry: the military display on every engine). renderer = { - create: ({ viewer, engine = null, adopt }) => - engine - ? rendererFor('military', engine, { viewer, adopt }) - : createMilitaryCesiumRenderer(null, { viewer, adopt }), + create: ({ engine = null } = {}) => rendererFor('military', engine), }, }) { const { groundFloor, meshFloor, groundSnap } = surface; @@ -35,9 +32,7 @@ export function createApplicationMilitary({ services: { picking, selection, - // The engine's military renderer from the registry (render/cesium.js - // on Cesium); a bare viewer without an engine (unit tests) draws on - // Cesium. + // The engine's military renderer from the registry (render/display.js). renderer, aircraftPresentation, camera: trackedCamera, diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 39b418db0..6a37d1e90 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -10,7 +10,8 @@ import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesiu // availability API built on it. // // A renderer factory `(engine, options) => renderer` draws one layer on one -// engine kind (`render/cesium.js`, `render/navara.js` of a layer). Cesium +// engine kind (`render/cesium.js`, `render/navara.js` of a layer), or on +// every kind (its `render/display.js`, registered for each). Cesium // layers without a render/cesium.js draw themselves: they are registered as // CESIUM_BUILTIN. Cesium entries are registered here, statically (main // chunk); Navara factories are registered by the Navara mode after its @@ -32,9 +33,9 @@ import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesiu // `availability` provider (`{ engine, unavailable() }` is the part it reads). import { APPLICATION_LAYER_DIRECTORY } from './directory.js'; -import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js'; -import { createMilitaryCesiumRenderer } from '../../layers/military/render/cesium.js'; -import { createLocalAdsbCesiumRenderer } from '../../layers/localAdsb/render/cesium.js'; +import { createFlightsDisplay } from '../../layers/flights/render/display.js'; +import { createMilitaryDisplay } from '../../layers/military/render/display.js'; +import { createLocalAdsbDisplay } from '../../layers/localAdsb/render/display.js'; import { createSatellitesCesiumRenderer } from '../../layers/satellites/render/cesium.js'; import { createVesselsCesiumRenderer } from '../../layers/vessels/render/cesium.js'; @@ -107,9 +108,9 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({ 'local-firms': createFirmsCesiumRenderer, 'rocket-launches': createLaunchesCesiumRenderer, satellites: createSatellitesCesiumRenderer, - flights: createFlightsCesiumRenderer, - military: createMilitaryCesiumRenderer, - 'local-adsb': createLocalAdsbCesiumRenderer, + flights: createFlightsDisplay, + military: createMilitaryDisplay, + 'local-adsb': createLocalAdsbDisplay, 'ais-live-vessels': createVesselsCesiumRenderer, }); diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index 36857dbfd..d38928234 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -1,4 +1,5 @@ import test from 'node:test'; +import { readFileSync } from 'node:fs'; import assert from 'node:assert/strict'; import { CESIUM_BUILTIN, @@ -9,6 +10,7 @@ import { layerAvailableOn, } from './renderers.js'; import { APPLICATION_LAYER_DIRECTORY } from './directory.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; test('a registry knows which engine renders which layer', () => { const registry = createRendererRegistry(); @@ -80,16 +82,43 @@ test('the aircraft, vessels, satellites, launches and earthquakes have extracted }, ); assert.equal(vessels.kind, 'cesium'); - const viewer = { scene: {}, camera: {} }; - const renderer = rendererFor('flights', { kind: 'cesium' }, { viewer }); - assert.equal(renderer.kind, 'cesium'); - assert.equal(renderer.viewer, viewer); - const military = rendererFor('military', { kind: 'cesium' }, { viewer }); - assert.equal(military.kind, 'cesium'); - assert.equal(military.viewer, viewer); assert.equal(rendererFor('satellites', { kind: 'cesium' }).kind, 'cesium'); }); +test('the aircraft family draws through one display, the same factory on every engine', async () => { + const displays = { + flights: ['flights', 'createFlightsDisplay'], + military: ['military', 'createMilitaryDisplay'], + 'local-adsb': ['localAdsb', 'createLocalAdsbDisplay'], + }; + // The Navara mode registers the same modules' factories. + const navaraMode = readFileSync( + new URL('../navaraMode.js', import.meta.url), + 'utf8', + ); + const drawAtlas = async () => ({ canvas: {}, cells: {} }); + for (const [id, [directory, name]] of Object.entries(displays)) { + const factory = ( + await import(`../../layers/${directory}/render/display.js`) + )[name]; + assert.equal(CESIUM_LAYER_RENDERERS[id], factory, id); + assert.match( + navaraMode, + new RegExp( + `'?${id}'?: async \\(\\) =>\\s*\\(await import\\('[^']+/render/display\\.js'\\)\\)\\s*\\.${name}\\b`, + ), + id, + ); + for (const kind of ['cesium', 'navara']) { + const engine = createFakeEngine({ kind }); + const display = factory(engine, { drawAtlas }); + assert.equal(display.engine, engine, `${id} on ${kind}`); + display.destroy(); + engine.destroy(); + } + } +}); + test('the layer availability API: engine, isAvailable, available, unavailable', () => { const registry = createRendererRegistry(); registry.register('navara', { flights: () => ({}) }); diff --git a/src/app/layers/transit.js b/src/app/layers/transit.js index 9a204aabd..dc14a05bb 100644 --- a/src/app/layers/transit.js +++ b/src/app/layers/transit.js @@ -30,7 +30,7 @@ export function createApplicationTransit({ rendererFactory = ({ engine, ...context }) => engine?.kind === 'navara' ? rendererFor('transit', engine, context) - : createTransitCesiumRenderer(context), + : createTransitCesiumRenderer({ ...context, engine }), }) { if (!surface?.groundFloor || !surface?.meshFloor) throw new TypeError('Transit needs the application surface services'); diff --git a/src/app/layers/wildlifeSightings.js b/src/app/layers/wildlifeSightings.js index 6d181f2c7..512fa9d11 100644 --- a/src/app/layers/wildlifeSightings.js +++ b/src/app/layers/wildlifeSightings.js @@ -1,14 +1,12 @@ -import * as Cesium from 'cesium'; import { createWildlifeSightingsLayer } from '../../layers/wildlifeSightings/index.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; -import * as sprites from '../../data/spriteOrder.js'; import * as render from '../../renderGovernor.js'; import * as context from '../../data/contextStore.js'; import * as focus from '../../worldFocus.js'; import { isPointerFree } from '../../data/inputOwnership.js'; -/** Bind the wildlife layer to the application's scene and selection owners. */ +/** Bind the wildlife layer to the application's selection and overlay owners. */ export function createApplicationWildlifeSightings(options) { return createWildlifeSightingsLayer({ overlayHost: { @@ -17,11 +15,8 @@ export function createApplicationWildlifeSightings(options) { clearSource: overlays.clearOverlaySource, hitTest: overlays.hitTestWorldOverlay, }, - screenSpaceEventHandlerFactory: (viewer) => - new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), picking, pointer: { isPointerFree }, - sprites, render, context, focus, diff --git a/src/app/loadCesium.test.mjs b/src/app/loadCesium.test.mjs index 3197d3d1a..374259713 100644 --- a/src/app/loadCesium.test.mjs +++ b/src/app/loadCesium.test.mjs @@ -112,7 +112,7 @@ test('the standalone startup graph can be imported before the Cesium SDK exists' const { createStandaloneCatalog } = await import('./src/standalone/catalog.js'); const lifetime = new AbortController(); const catalog = createStandaloneCatalog({ engineKind: 'navara', signal: lifetime.signal }); - if (catalog.layers.length !== 37) throw new Error('Incomplete cold Navara catalog'); + if (catalog.layers.length !== 38) throw new Error('Incomplete cold Navara catalog'); lifetime.abort(); `; const result = spawnSync( diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index fbc37a348..62b7d57ca 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -54,6 +54,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'gnss-integrity', 's2-ghostmaps', 'drone-airspace', + 'wildlife-sightings', 'alpr-cameras', 'street-level', 'satellites', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 360dea3d6..ea2d133b1 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -61,6 +61,7 @@ export function navaraModeNotice(availability) { /** Static attribution for the shell and the layers available on Navara. */ const NAVARA_CREDIT_KEYS = new Set([ + 'inaturalist', 'photon-geocoder', 'openstreetmap', 'reearth-terrain', @@ -244,6 +245,8 @@ export async function registerNavaraLayerRenderers() { 'osm-places': async () => (await import('../layers/osmPlaces/render/navara.js')) .createOsmPlacesNavaraRenderer, + // Draws itself through the globe contract (engine.movers, engine.lines). + 'wildlife-sightings': async () => () => null, 'drone-airspace': async () => { const { createDroneAirspaceNavaraRenderer } = await import('../layers/droneAirspace/render/navara.js'); @@ -289,15 +292,16 @@ export async function registerNavaraLayerRenderers() { satellites: async () => (await import('../layers/satellites/render/navara.js')) .createSatellitesNavaraRenderer, + // The aircraft family: the same displays as on Cesium (renderers.js). flights: async () => - (await import('../layers/flights/render/navara.js')) - .createFlightsNavaraRenderer, + (await import('../layers/flights/render/display.js')) + .createFlightsDisplay, military: async () => - (await import('../layers/military/render/navara.js')) - .createMilitaryNavaraRenderer, + (await import('../layers/military/render/display.js')) + .createMilitaryDisplay, 'local-adsb': async () => - (await import('../layers/localAdsb/render/navara.js')) - .createLocalAdsbNavaraRenderer, + (await import('../layers/localAdsb/render/display.js')) + .createLocalAdsbDisplay, 'ais-live-vessels': async () => (await import('../layers/vessels/render/navara.js')) .createVesselsNavaraRenderer, diff --git a/src/app/operations.js b/src/app/operations.js index 02a112ed9..0053d8952 100644 --- a/src/app/operations.js +++ b/src/app/operations.js @@ -35,8 +35,8 @@ export function createApplicationOperations({ requests, signal, eventTarget }) { requests, surface, annotationResolver, - searchAndFlyTo: (viewer, query, options = {}) => - searchAndFlyTo(viewer, query, { + searchAndFlyTo: (engine, query, options = {}) => + searchAndFlyTo(engine, query, { ...options, features, ground: surface.groundFloor, diff --git a/src/camera.js b/src/camera.js index 4eaeee29a..12c3da7bf 100644 --- a/src/camera.js +++ b/src/camera.js @@ -1,14 +1,11 @@ import * as geo from './geo/index.js'; -import { engineFromViewer } from './globe/cesium/engineFromViewer.js'; /** * Set camera to Austin on load with a cinematic fly-in. - * @param {import('./globe/contract.js').GlobeEngine | object} target The globe - * engine, or a viewer registered for one. + * @param {import('./globe/contract.js').GlobeEngine | null} engine * @returns {Function} Cancels the pending or active startup flight. */ -export function flyToAustin(target) { - const engine = engineFromViewer(target); +export function flyToAustin(engine) { if (!engine) return () => {}; // Start from a high altitude, then fly down engine.camera.setView({ diff --git a/src/cameraGroundGuard.js b/src/cameraGroundGuard.js index 29e1ad90c..cf13bd48e 100644 --- a/src/cameraGroundGuard.js +++ b/src/cameraGroundGuard.js @@ -16,7 +16,6 @@ */ import * as geo from './geo/index.js'; import { ownCameraArrival } from './data/cameraArrival.js'; -import { engineFromViewer } from './globe/cesium/engineFromViewer.js'; /** Minimum eye height above the rendered surface for a usable view. */ export const MIN_EYE_CLEARANCE_M = 120; @@ -80,14 +79,13 @@ export function groundClearanceDeficitM( * (`camera.commanded`: a flight, setView, lookAt, cancelFlight or, on * Cesium, a new tracked entity), or `isStale()` reporting that a newer * arrival owns the camera. - * @param {import('./globe/contract.js').GlobeEngine | object} target The - * globe engine, or a viewer registered for one. + * @param {import('./globe/contract.js').GlobeEngine | null} engine * @param {{lat: number, lon: number}} at Destination being framed. * @param {{clearanceM?: number, attempts?: number, intervalMs?: number, * isStale?: () => boolean, onLift?: (liftM: number) => void}} [options] * @returns {() => void} Cancel function. */ -export function guardCameraAboveGround(target, at, options = {}) { +export function guardCameraAboveGround(engine, at, options = {}) { const { clearanceM = MIN_EYE_CLEARANCE_M, attempts = GUARD_ATTEMPTS, @@ -95,7 +93,6 @@ export function guardCameraAboveGround(target, at, options = {}) { isStale = () => false, onLift = null, } = options; - const engine = engineFromViewer(target); if (!engine || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon)) return () => {}; diff --git a/src/cameraGroundGuard.test.mjs b/src/cameraGroundGuard.test.mjs index 9ffa794bb..e2be77f87 100644 --- a/src/cameraGroundGuard.test.mjs +++ b/src/cameraGroundGuard.test.mjs @@ -12,7 +12,6 @@ import { guardCameraAboveGround, } from './cameraGroundGuard.js'; import { createCesiumEngine } from './globe/cesium/createCesiumEngine.js'; -import { registerViewerEngine } from './globe/cesium/engineFromViewer.js'; import { createFakeEngine } from './globe/testing/fakeEngine.js'; test('a buried camera is lifted clear of the surface', () => { @@ -50,8 +49,8 @@ test('the clearance frames a subject without turning into an overflight', () => assert.ok(MIN_EYE_CLEARANCE_M <= 250); }); -// A viewer double under the real Cesium engine, registered as the app does, -// so the guard runs the shell's engine path against Cesium-shaped calls. +// A viewer double under the real Cesium engine (`viewer.engine`), so the +// guard runs the shell's engine path against Cesium-shaped calls. function fakeViewer({ cameraHeightM, surfaceM, canvas = null }) { const flights = []; const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM); @@ -75,7 +74,6 @@ function fakeViewer({ cameraHeightM, surfaceM, canvas = null }) { }, }; viewer.engine = createCesiumEngine(viewer); - registerViewerEngine(viewer, viewer.engine); return viewer; } @@ -83,7 +81,7 @@ test('the guard lifts a buried arrival once the surface answers', async () => { const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 }); const lifts = []; guardCameraAboveGround( - viewer, + viewer.engine, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1, onLift: (m) => lifts.push(m) }, ); @@ -102,7 +100,7 @@ test('the guard waits for streaming tiles and yields to a newer arrival', async let stale = false; const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: () => answer }); guardCameraAboveGround( - viewer, + viewer.engine, { lat: 30.3125, lon: -97.765 }, { intervalMs: 2, attempts: 20, isStale: () => stale }, ); @@ -116,7 +114,7 @@ test('the guard waits for streaming tiles and yields to a newer arrival', async test('the guard leaves a clear view untouched', async () => { const viewer = fakeViewer({ cameraHeightM: 400, surfaceM: 171 }); - guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1 }); + guardCameraAboveGround(viewer.engine, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1 }); await new Promise((resolve) => setTimeout(resolve, 20)); assert.equal(viewer.flights.length, 0); }); @@ -147,7 +145,7 @@ function handoffFixture(t) { getDataManager() {}, interruptCameraMotion() {}, stopOrbit() {}, isCockpitActive: () => false, showToast() {}, }); - guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 }); + guardCameraAboveGround(viewer.engine, { lat: 30.3, lon: -97.7 }); return { viewer, navigation, originalFly }; } @@ -168,7 +166,7 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI', viewer.scene.canvas.ownerDocument.dispatchEvent(new Event('keydown')); } else if (handoff === 'app-stop') navigation.stop(); else if (handoff.startsWith('layer-')) { - const lifecycle = new LayerLifecycle(viewer); + const lifecycle = new LayerLifecycle(viewer, { engine: viewer.engine }); lifecycle.register({ id: 'test', init() {}, enable() {}, disable() {}, destroy() {} }); await lifecycle.setEnabled('test', true); if (handoff === 'layer-disable') await lifecycle.setEnabled('test', false); diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index ea2c0e961..acec145cc 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -398,7 +398,7 @@ test('refused canned destinations commit no location or POI state', () => { test('world-focus listener lifecycle is symmetric and idempotent', () => { assert.match(ui, /registerWorldFocusRequestListener\(\s*window,/); assert.match(ui, /routeWorldFocusRequest\(/); - assert.match(ui, /flyToWorldTarget\(this\.engine \?\? this\.viewer, detail\)/); + assert.match(ui, /flyToWorldTarget\(this\.engine, detail\)/); assert.match(ui, /this\._removeWorldRequestFocusListener\?\.\(\);/); assert.match(ui, /this\._removeWorldRequestFocusListener = null;/); }); diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index 1dc777c94..c224b71d8 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -10,7 +10,6 @@ import { } from './cameraVerbs.js'; import { createGevActionRunner } from './voice/gevActions.js'; import { createCameraViewer } from './globe/cesium/testing/cameraViewer.mjs'; -import { registerViewerEngine } from './globe/cesium/engineFromViewer.js'; const radians = Cesium.Math.toRadians; const degrees = Cesium.Math.toDegrees; @@ -35,8 +34,6 @@ function harness( destination: Cesium.Cartesian3.fromDegrees(-0.1276, 51.5072, height), orientation: { heading: radians(heading), pitch: radians(pitch), roll: 0 }, }); - // A voice runner built on the viewer reaches the same engine. - const unregister = registerViewerEngine(viewer, engine); // The app's view target: the ground point under the screen centre. const viewTarget = () => { const ray = new Cesium.Ray(camera.positionWC, camera.directionWC); @@ -47,7 +44,6 @@ function harness( initCameraVerbs(engine, pivot ? viewTarget : () => undefined); t.after(() => { interruptCameraMotion('test-cleanup'); - unregister(); engine.destroy(); globalThis.performance = realPerformance; }); @@ -59,7 +55,7 @@ function harness( for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(); assert.equal(getActiveCameraMotion(), null, 'tilt must finish'); }; - return { viewer, camera, target, tick, finish, viewTarget }; + return { viewer, camera, engine, target, tick, finish, viewTarget }; } const pitchDeg = (camera) => degrees(camera.pitch); @@ -417,6 +413,7 @@ test('a voice zoom during a pivot tilt says what it changed', async (t) => { const h = harness(t, { height: 400 }); const runner = createGevActionRunner({ viewer: h.viewer, + engine: h.engine, styleManager: {}, dataManager: { layers: new Map(), getAll: () => [] }, }); @@ -653,6 +650,7 @@ test('a voice zoom during a tilt to an angle passes on the new end angle', async const h = harness(t, { height: 400_000 }); const runner = createGevActionRunner({ viewer: h.viewer, + engine: h.engine, styleManager: {}, dataManager: { layers: new Map(), getAll: () => [] }, }); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 864ab8304..1c9245d8a 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -38,7 +38,6 @@ import { holdContinuousRender, releaseContinuousRender, } from './renderGovernor.js'; -import { isGlobeEngine } from './globe/contract.js'; let _isCockpitActive = null; /** °/s by speed word — orbit; pan uses fractions of view height/s. */ @@ -1285,7 +1284,7 @@ const ENGINE_RIG_MEMBERS = Object.freeze([ * @returns {boolean} */ export function cameraVerbsSupported(target) { - if (!isGlobeEngine(target) || target.isDestroyed?.()) return false; + if (!target || target.isDestroyed?.()) return false; return ENGINE_RIG_MEMBERS.every( (path) => typeof path @@ -2210,7 +2209,7 @@ export function initCameraVerbs( _isCockpitActive = isCockpitActive; return; } - if (!isGlobeEngine(target)) return; + if (!target) return; const previous = _rig; const next = engineRigFor(target); if (_active && (_active.rig === previous || previous === null)) { diff --git a/src/celestialRing.js b/src/celestialRing.js index dfd3df4fd..ebf8a4315 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -1,6 +1,5 @@ import * as geo from './geo/index.js'; import { createCelestialView } from './celestialRing/view.js'; -import { engineFromViewer } from './globe/cesium/engineFromViewer.js'; import { cesiumIcrfToFixed, projectCelestialPoint, @@ -245,6 +244,9 @@ export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) { * @param {number} height - Viewport height in CSS pixels. * @param {object} [scratchCenter] - Reusable Cesium Cartesian2 result. * @param {object} [scratchToCenter] - Reusable Cesium Cartesian3 result. + * @param {import('./globe/contract.js').GlobeProjector | null} [projector] - + * The globe engine's projector; without one, Cesium's SceneTransforms + * (the same projection on a Cesium scene). * @returns {object|null} */ export function projectEarthDiscToViewport( @@ -253,6 +255,7 @@ export function projectEarthDiscToViewport( height, scratchCenter = undefined, scratchToCenter = undefined, + projector = null, ) { const camera = viewer?.camera; const scene = viewer?.scene; @@ -271,7 +274,6 @@ export function projectEarthDiscToViewport( ); if (geo.cartesian3.dot(camera.directionWC, toCenter) <= 0) return null; - const projector = engineFromViewer(viewer)?.projector; const center = projector ? projector.worldToWindow(geo.cartesian3.ZERO, scratchCenter) : projectCelestialPoint(scene, geo.cartesian3.ZERO, scratchCenter); @@ -398,14 +400,19 @@ let rings = 0; export class CelestialRing { /** * @param {Cesium.Viewer} viewer - * @param {{enabled?:boolean,onAutoDisable?:Function,store:object}} options + * @param {{enabled?:boolean,onAutoDisable?:Function,store:object, + * engine?:object}} options * `store` is the celestial ring store (src/ui/stores/celestialRing.js); * the keyhole helpers here stay free of Vue for the packages that use - * them. + * them. `engine`: the globe engine, whose projector places the Earth disc. */ - constructor(viewer, { enabled = true, onAutoDisable = null, store } = {}) { + constructor( + viewer, + { enabled = true, onAutoDisable = null, store, engine = null } = {}, + ) { this._engineView = viewer?.kind ? createCelestialView(viewer) : null; this.viewer = this._engineView?.view ?? viewer; + this._projector = engine?.projector ?? null; this.enabled = !!enabled; this.visible = false; this._onAutoDisable = @@ -684,6 +691,7 @@ export class CelestialRing { height, this._screenCenter, this._toCenter, + this._projector, ); } diff --git a/src/celestialRing/view.js b/src/celestialRing/view.js index 2aca415a2..470953076 100644 --- a/src/celestialRing/view.js +++ b/src/celestialRing/view.js @@ -1,6 +1,5 @@ // The shared engine view has no renderer-library imports. Reuse its cached // camera basis for the celestial canvas, with flight and host ownership here. -import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js'; import { createEngineLayerView } from '../layers/shared/render/engineView.js'; export function createCelestialView(engine) { const adapter = createEngineLayerView(engine, { layerId: 'celestial-ring' }); @@ -11,11 +10,9 @@ export function createCelestialView(engine) { view.scene.requestRender = () => engine.render.request('celestial-ring'); view.camera.flyTo = ({ easingFunction, ...options }) => engine.camera.flyTo({ ...options, easing: 'cubicInOut' }); - const unregister = registerViewerEngine(view, engine); return { view, destroy() { - unregister(); adapter.destroy(); }, }; diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index f8dc12117..032169eb6 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -10,6 +10,7 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { SceneControls } from './ui/sceneControls.js'; import { isRenderedOnScreen } from './ui/cockpitPresentation.js'; import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; +import { cockpitHost } from './ui/cockpitHost.js'; import { enter as cockpitEnter, exit as cockpitExit, @@ -223,11 +224,11 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc assert.match(enter, /activeElement/); assert.match(enter, /mapViewButton.*focus|focus.*mapViewButton/s); assert.match(exit, /focus\(\{ preventScroll: true \}\)/); - assert.match(exit, /this\.host\.restore\(entity, this\.restoreTrackingFrame\)/); + assert.match(exit, /this\.host\.restore\(entity\)/); assert.doesNotMatch(exit, /applyTrackedCameraFrame/); assert.match( - ui, - /restoreTrackingFrame: \(entity\) => \{[\s\S]*?gevTrackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/, + cockpitHost.toString(), + /restore: \(target\) => target\.resumeAircraftFollow\(\)/, 'Cockpit exit must return camera-frame ownership through the source layer so Contact Focus cannot accumulate a second owner', ); }); @@ -295,13 +296,13 @@ test('the Contact panel never hides itself out from under its own NEXT button', ); }); -test('the cockpit reads its aircraft from the layer that owns Cesium tracking', () => { +test('the cockpit reads its aircraft from the layer that owns the tracking', () => { const read = readAircraftInfo.toString(); assert.ok(read, 'readAircraftInfo is missing'); assert.match(read, /resolveTrackedAircraftInfo\(\{/); assert.match(read, /gevTrackedId/); - // In cockpit mode the controller moves the entity off viewer.trackedEntity, - // so its own handle is the tracked identity there. + // In cockpit mode the controller releases the engine's follow of the + // aircraft, so its own handle is the tracked identity there. assert.match(read, /this\.host\.followed\(\) \|\| this\.trackedEntity/); }); @@ -1380,9 +1381,9 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => { }); test('cockpit state cannot report entryAllowed while already active', () => { - // Cockpit takes the entity off viewer.trackedEntity on entry and NEXT puts - // one back, so this flipped true/false between calls while active stayed - // true — read by the voice model as a broken half-entered state. + // Cockpit releases the engine's follow on entry and NEXT starts another, + // so this flipped true/false between calls while active stayed true — + // read by the voice model as a broken half-entered state. const state = ui.slice( ui.indexOf(' getCockpitState() {'), ui.indexOf(' _retargetCockpitEntryLayer({'), diff --git a/src/data/cameraArrival.js b/src/data/cameraArrival.js index 82b3375a7..64ed9c9ab 100644 --- a/src/data/cameraArrival.js +++ b/src/data/cameraArrival.js @@ -1,26 +1,19 @@ -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; - /** * Arrival corrections belong to one globe engine and yield to its next camera - * owner. Callers may name the engine or (layers that still hold one) the - * viewer it is registered for; both reach the same correction. + * owner. */ const arrivals = new WeakMap(); -const ownerOf = (engineOrViewer) => - engineFromViewer(engineOrViewer) ?? engineOrViewer; - /** Cancel an outstanding arrival correction before navigation or teardown. */ -export function cancelCameraArrival(engineOrViewer) { - if (engineOrViewer) arrivals.get(ownerOf(engineOrViewer))?.(); +export function cancelCameraArrival(engine) { + if (engine) arrivals.get(engine)?.(); } /** Register a correction and return an idempotent ownership release. */ -export function ownCameraArrival(engineOrViewer, cancel) { - const owner = ownerOf(engineOrViewer); - cancelCameraArrival(owner); - arrivals.set(owner, cancel); +export function ownCameraArrival(engine, cancel) { + cancelCameraArrival(engine); + arrivals.set(engine, cancel); return () => { - if (arrivals.get(owner) === cancel) arrivals.delete(owner); + if (arrivals.get(engine) === cancel) arrivals.delete(engine); }; } diff --git a/src/data/cameraArrival.test.mjs b/src/data/cameraArrival.test.mjs index 49f49eee4..544eb80c2 100644 --- a/src/data/cameraArrival.test.mjs +++ b/src/data/cameraArrival.test.mjs @@ -1,13 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; -import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js'; import { cancelCameraArrival, ownCameraArrival } from './cameraArrival.js'; -test('an arrival owned on the engine yields to a cancel through its viewer', () => { +test('an arrival owned on the engine yields to a cancel on the engine', () => { const engine = createFakeEngine(); - const viewer = {}; - const unregister = registerViewerEngine(viewer, engine); const cancelled = []; // A correction releases its ownership when it stops, as the ground guard does. const own = (key, name) => { @@ -17,12 +14,11 @@ test('an arrival owned on the engine yields to a cancel through its viewer', () release(); }); }; - own(engine, 'engine-owned'); - cancelCameraArrival(viewer); - own(viewer, 'viewer-owned'); + own(engine, 'first'); cancelCameraArrival(engine); - assert.deepEqual(cancelled, ['engine-owned', 'viewer-owned']); - unregister(); + own(engine, 'second'); + cancelCameraArrival(engine); + assert.deepEqual(cancelled, ['first', 'second']); }); test('a newer arrival replaces the older one and release is idempotent', () => { diff --git a/src/data/cameraSensitivity.js b/src/data/cameraSensitivity.js index 2a5ed685e..3dfda8901 100644 --- a/src/data/cameraSensitivity.js +++ b/src/data/cameraSensitivity.js @@ -24,8 +24,6 @@ * for, only that nobody's answer is taken away while they still need it. */ -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; - /** @type {WeakMap}>} */ const _cameras = new WeakMap(); @@ -33,13 +31,11 @@ const _cameras = new WeakMap(); const DEFAULT_PERCENTAGE_CHANGED = 0.5; /** - * One key per camera: the engine's camera. A globe engine claims through its - * camera; a Cesium viewer or its camera through the engine registered for - * that viewer (engineFromViewer); anything else is the camera itself (a - * camera has no `camera` of its own). + * One key per camera: a globe engine claims through its camera, and so does + * a viewer; anything else is the camera itself (a camera has no `camera` of + * its own). Every layer on one globe claims through its engine. */ -const cameraOf = (target) => - engineFromViewer(target)?.camera ?? target?.camera ?? target; +const cameraOf = (target) => target?.camera ?? target; function ledgerFor(camera) { let ledger = _cameras.get(camera); @@ -64,9 +60,8 @@ function applyLedger(camera, ledger) { /** * Ask for a camera-change sensitivity at least as fine as `value`. - * @param {object|null|undefined} target A globe engine, its camera, or a - * Cesium viewer or camera (anything with `percentageChanged`). A viewer - * with a registered engine, and its camera, claim on the engine camera. + * @param {object|null|undefined} target A globe engine or its camera, or a + * camera (anything with `percentageChanged`) where no engine is given. * @param {string} owner Stable claim name, usually the layer id. * @param {number} value Requested `percentageChanged`. * @returns {number|null} The value now in force, or null when there is no camera. diff --git a/src/data/cameraSensitivity.test.mjs b/src/data/cameraSensitivity.test.mjs index 8570ce9a7..5f83f191f 100644 --- a/src/data/cameraSensitivity.test.mjs +++ b/src/data/cameraSensitivity.test.mjs @@ -26,30 +26,26 @@ test('claims on a globe engine set its camera sensitivity and share one ledger', assert.deepEqual(cameraSensitivityClaims(engine), []); }); -test("a Cesium viewer's camera claims on the same key as its engine's camera", async () => { +test("a Cesium engine's claims share one ledger and set its viewer's camera", async () => { const { createFakeViewer } = await import('../globe/cesium/testing/fakeViewer.mjs'); const { createCesiumEngine } = await import('../globe/cesium/createCesiumEngine.js'); - const { registerViewerEngine } = - await import('../globe/cesium/engineFromViewer.js'); const viewer = createFakeViewer(); const engine = createCesiumEngine(viewer, { requestRender: () => {} }); - const unregister = registerViewerEngine(viewer, engine); try { - claimCameraSensitivity(viewer.camera, 'traffic', 0.05); + claimCameraSensitivity(engine.camera, 'traffic', 0.05); claimCameraSensitivity(engine, 'transit', 0.05); // One ledger: both claims are on it, and releasing one keeps the other. - assert.deepEqual(cameraSensitivityClaims(viewer).sort(), [ + assert.deepEqual(cameraSensitivityClaims(engine).sort(), [ 'traffic', 'transit', ]); releaseCameraSensitivity(engine, 'transit'); assert.equal(viewer.camera.percentageChanged, 0.05); - releaseCameraSensitivity(viewer.camera, 'traffic'); + releaseCameraSensitivity(engine.camera, 'traffic'); assert.equal(viewer.camera.percentageChanged, 0.5); } finally { - unregister(); engine.destroy(); } }); diff --git a/src/data/cockpitDetectionSubjects.test.mjs b/src/data/cockpitDetectionSubjects.test.mjs index 8421267fc..8f431978d 100644 --- a/src/data/cockpitDetectionSubjects.test.mjs +++ b/src/data/cockpitDetectionSubjects.test.mjs @@ -11,6 +11,7 @@ import militaryFlightsLayer, { _setCockpitDetectionSubjectForTest as setMilitaryCockpitSubject, _setTrackedMilitaryRefreshStateForTest, } from './militaryFlights.js'; +import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; const SUBJECT = 'abc123'; const NEXT_SUBJECT = 'def456'; @@ -25,6 +26,7 @@ const LAYERS = [ setSubject: setFlightsCockpitSubject, seed(icao24) { _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24, entity: null, billboard: candidateBillboard(icao24), @@ -46,6 +48,7 @@ const LAYERS = [ setSubject: setMilitaryCockpitSubject, seed(icao24) { _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24, entity: null, billboard: candidateBillboard(icao24), diff --git a/src/data/cockpitModels.test.mjs b/src/data/cockpitModels.test.mjs index 07924b551..f7070716e 100644 --- a/src/data/cockpitModels.test.mjs +++ b/src/data/cockpitModels.test.mjs @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -28,15 +29,17 @@ for (const layer of LAYERS) { const source = readLayerSource(layer.path); test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => { - // The aircraft renderer loads through its `loadModel`, Cesium.Model.fromGltfAsync - // unless a test injects a loader. - assert.match(source, /loadModel = \(options\) => Cesium\.Model\.fromGltfAsync\(options\)/); - const calls = [...source.matchAll(/(?:Cesium\.Model\.fromGltfAsync|\bloadModel)\(\{([\s\S]*?)\}\)/g)]; - assert.ok(calls.length >= 3, `expected fleet, tracked, and preload model calls; found ${calls.length}`); - for (const [index, call] of calls.entries()) { - assert.match(call[1], /\basynchronous:\s*false\b/, - `Model.fromGltfAsync call ${index + 1} must keep bounded GLB readiness independent of tile jobs`); - } + // The layer's models are the engine's model movers (the aircraft display); + // on Cesium each instance loads through Cesium.Model.fromGltfAsync there. + assert.doesNotMatch(source, /Cesium\.Model\.fromGltfAsync/); + const movers = readFileSync( + new URL('../globe/cesium/movers.js', import.meta.url), + 'utf8', + ); + const calls = [...movers.matchAll(/\.loadModel\(\{([\s\S]*?)\}\)/g)]; + assert.equal(calls.length, 1, 'one model load in the Cesium movers'); + assert.match(calls[0][1], /\basynchronous:\s*false\b/, + 'Model.fromGltfAsync must keep bounded GLB readiness independent of tile jobs'); }); test(`${layer.name}: Cockpit 3D obeys the shared Display toggle`, () => { diff --git a/src/data/detection.js b/src/data/detection.js index cdc5a90b0..254e3858b 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -1,4 +1,3 @@ -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; import { paintTransitBracket } from './detectionDraw.js'; import * as geo from '../geo/index.js'; import { governorRequestRender } from '../renderGovernor.js'; @@ -198,18 +197,15 @@ export function countFadingRenderEntries(renderEntries) { return count; } // --------------------------------------------------------------------------- -// Module-level state — singleton lifecycle tied to the Cesium viewer +// Module-level state — singleton lifecycle tied to the globe engine // --------------------------------------------------------------------------- -/** @type {Cesium.Viewer|null} */ -let _viewer = null; +/** @type {import('../globe/contract.js').GlobeEngine|null} */ +let _engine = null; -/** The camera height (m): the engine's, else a bare viewer's (tests). */ +/** The camera height (m), or undefined before init. */ function cameraHeight() { - const engine = engineFromViewer(_viewer); - return engine - ? engine.camera.height() - : _viewer?.camera?.positionCartographic?.height; + return _engine?.camera.height(); } /** @type {number} Current mode index into MODE_LABELS */ let _mode = MODE_OFF; @@ -297,12 +293,12 @@ let _cockpitModeListener = null; /** * Initializes detection inside the shared world-overlay host and stores * references to data layers. The host owns the canvas and render listener. - * @param {object} viewer - The globe engine (or a Cesium viewer; only the - * camera height is read, through the engine when one is registered). + * @param {import('../globe/contract.js').GlobeEngine} engine - The globe + * engine; only its camera height is read. * @param {Array} layers - Data layer modules that may implement getDetectableObjects(). * @param {Function} onModeChange - Callback invoked with the new mode label string on mode changes. */ -export function initDetection(viewer, layers, onModeChange) { +export function initDetection(engine, layers, onModeChange) { if (_cockpitModeListener && typeof window !== 'undefined') { window.removeEventListener( 'gev:cockpit-mode-changed', @@ -311,7 +307,7 @@ export function initDetection(viewer, layers, onModeChange) { } _hostLane?.unregister?.(); _calloutLane?.unregister?.(); - _viewer = viewer; + _engine = engine; _layers = layers; _onModeChange = onModeChange; _debugBanner = detectionDebugRequested( @@ -367,7 +363,7 @@ export function destroyDetection() { _calloutLane = null; _transitBracketPaths.clear(); _calloutCount = 0; - _viewer = null; + _engine = null; _layers = []; _onModeChange = null; _hostCanvas = null; diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index ed564883c..4a44ce919 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -364,7 +364,7 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste const modes = []; try { initWorldOverlay(env.engine); - initDetection(env.viewer, [detectableLayer()], (mode) => modes.push(mode)); + initDetection(env.engine, [detectableLayer()], (mode) => modes.push(mode)); assert.equal(env.postRender.listeners.size, 1); assert.equal(env.document.getElementById('detection-overlay'), null); assert.ok(env.document.getElementById('world-overlay-canvas')); @@ -518,7 +518,7 @@ test('callouts stop painting the moment the last detectable object goes away', ( }]; try { initWorldOverlay(env.engine); - initDetection(env.viewer, [{ id: 'flights', getDetectableObjects: () => objects }], () => {}); + initDetection(env.engine, [{ id: 'flights', getDetectableObjects: () => objects }], () => {}); setMode('DENSE'); env.advance(250); env.postRender.raise(); @@ -554,7 +554,7 @@ test('every style paints its OWN plate token through the production callout path const env = installEnvironment(); try { initWorldOverlay(env.engine); - initDetection(env.viewer, [mixedTierLayer()], () => {}); + initDetection(env.engine, [mixedTierLayer()], () => {}); setMode('DENSE'); settleFrame(env); settleFrame(env); @@ -592,7 +592,7 @@ test('space-tier callouts take the heavier plate while air contacts keep the lig const env = installEnvironment(); try { initWorldOverlay(env.engine); - initDetection(env.viewer, [mixedTierLayer()], () => {}); + initDetection(env.engine, [mixedTierLayer()], () => {}); setDetectionStyle('surveillance'); setMode('DENSE'); settleFrame(env); @@ -620,7 +620,7 @@ test('surveillance military tier reaches the dedicated render target as shipped const env = installEnvironment(); try { initWorldOverlay(env.engine); - initDetection(env.viewer, [militaryLayer()], () => {}); + initDetection(env.engine, [militaryLayer()], () => {}); setDetectionStyle('surveillance'); setMode('DENSE'); env.advance(250); @@ -660,7 +660,7 @@ test('Global Context echo brackets reach the canvas in the detection style colou echo, }); initWorldOverlay(env.engine); - initDetection(env.viewer, [militaryLayer()], () => {}); + initDetection(env.engine, [militaryLayer()], () => {}); setMode('DENSE'); setDetectionStyle('normal'); settleFrame(env); @@ -706,7 +706,7 @@ test('a Global Context echo bracket is drawn outside its contact bracket and dim level: () => 0.5, }); initWorldOverlay(env.engine); - initDetection(env.viewer, [militaryLayer()], () => {}); + initDetection(env.engine, [militaryLayer()], () => {}); setMode('DENSE'); setDetectionStyle('normal'); settleFrame(env); @@ -778,7 +778,7 @@ test('under the Cyber sonar sweep an echo is never brighter than its own bracket level: () => 1, }); initWorldOverlay(env.engine); - initDetection(env.viewer, [contact], () => {}); + initDetection(env.engine, [contact], () => {}); setMode('DENSE'); setDetectionStyle('normal'); settleFrame(env); @@ -840,7 +840,7 @@ test('resting Global Context pulses skip echo work for the whole frame, not per }, }); initWorldOverlay(env.engine); - initDetection(env.viewer, [layer], () => {}); + initDetection(env.engine, [layer], () => {}); setMode('DENSE'); settleFrame(env); levelCalls = 0; @@ -867,7 +867,7 @@ test('pathological detection paint holds alternate frames without freezing share const env = installEnvironment({ search: '?detectDebug=1' }); try { initWorldOverlay(env.engine); - initDetection(env.viewer, [detectableLayer()], () => {}); + initDetection(env.engine, [detectableLayer()], () => {}); setOverlayEntries('valve-card', [{ id: 'ambient-card', position: new Cesium.Cartesian3(0, 0, 0), @@ -974,7 +974,7 @@ test('the mode banner is absent by default and present behind the flag', () => { const env = installEnvironment({ search }); try { initWorldOverlay(env.engine); - initDetection(env.viewer, [detectableLayer()], () => {}); + initDetection(env.engine, [detectableLayer()], () => {}); setMode('DENSE'); env.advance(250); env.postRender.raise(); @@ -1001,7 +1001,7 @@ test('detection telemetry stays reachable programmatically with the banner hidde const env = installEnvironment(); try { initWorldOverlay(env.engine); - initDetection(env.viewer, [detectableLayer()], () => {}); + initDetection(env.engine, [detectableLayer()], () => {}); setMode('DENSE'); env.advance(250); env.postRender.raise(); @@ -1029,7 +1029,7 @@ test('civilian and military AIR brackets cover front, left, and right at Sparse type: 'AIR', })); initWorldOverlay(env.engine); - initDetection(env.viewer, [{ id: layerId, getDetectableObjects: () => objects }], () => {}); + initDetection(env.engine, [{ id: layerId, getDetectableObjects: () => objects }], () => {}); setDetectionTuning({ densityPct: 25 }); setMode('SPARSE'); settleFrame(env); @@ -1087,7 +1087,7 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', ( const env = installEnvironment(); try { initWorldOverlay(env.engine); - initDetection(env.viewer, [backdropLayer()], () => {}); + initDetection(env.engine, [backdropLayer()], () => {}); setDetectionStyle('normal'); setMode('DENSE'); settleFrame(env); @@ -1146,7 +1146,7 @@ test('a transit vehicle is a contact: box, route, mode and operator on the share try { initWorldOverlay(env.engine); initDetection( - env.viewer, + env.engine, [{ id: 'transit', getDetectableObjects: () => objects }], () => {}, ); diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 5beb83949..ec4da6eac 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -28,6 +28,8 @@ import { destroyTrackedReadout, initTrackedReadout, } from './trackedReadout.js'; +import { trackingEngine } from '../testSupport/viewerEngine.mjs'; +import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; test('share-Follow absence requires an accepted OpenSky snapshot', async () => { _setFlightTrackingRefreshOutcomeForTest({ status: 'source-unavailable' }); @@ -183,6 +185,7 @@ test('flights first update forwards caller cancellation into the feed request', test('nonempty OpenSky payload with zero usable rows cannot prove share target absence', async () => { _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: 'abc123', entity: { gevLabelModel: { title: 'WARM', details: [] } }, billboard: { show: false }, @@ -238,6 +241,7 @@ test('a newly seen aircraft appears at its delayed position, not ahead of it', a }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: 'seed00', entity: null, billboard: { @@ -326,6 +330,7 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA const billboardCollection = { show: false, remove() {} }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24, entity, billboard, @@ -475,8 +480,9 @@ test('real civil track path creates no native label and publishes every cached h globalThis.fetch = async () => ({ ok: false }); _setTrackedOverlayHostForTest(host); try { - initTrackedReadout(viewer); + initTrackedReadout(trackingEngine(viewer)); _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24, entity: null, billboard, @@ -736,6 +742,7 @@ function seedLabelContact({ meta: metaOverrides = {}, }) { _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: LABEL_ICAO, entity: null, billboard: { @@ -944,6 +951,7 @@ test('civil tracked readout: a callsign-less enriched contact reads as its regis const entity = { gevLabelModel: { title: 'OLD', details: [] } }; seedLabelContact({ callsign: ' ', registration: ' N123AB ', tracked: true }); _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: LABEL_ICAO, entity, billboard: { @@ -1082,6 +1090,7 @@ test('a newly seen taxiing aircraft starts on the floor under its delayed positi }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: 'seed00', entity: null, billboard: { diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 636a4fac5..309641783 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -1,4 +1,3 @@ -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; import { cancelCameraArrival } from './cameraArrival.js'; function cloneLayerParams(value) { @@ -84,8 +83,8 @@ export class LayerLifecycle { * @param {object | null} viewer The Cesium viewer (null on Navara). * @param {{ engine?: import('../globe/contract.js').GlobeEngine, * allowQaRegistration?: boolean, getSourceAvailability?: Function }} [options] - * `engine` (default: the viewer's registered engine): every module call - * gets `{ signal, engine }` as its second argument. + * `engine`: every module call gets `{ signal, engine }` as its second + * argument. * `attachSettleTimeoutMs`: how long a scene bind or detach waits for * asynchronous layer attachments before it continues without them. */ @@ -102,7 +101,7 @@ export class LayerLifecycle { this._catalogSourceAvailability = getSourceAvailability; this._visibilityTarget = visibilityTarget; this.viewer = viewer; - this.engine = engine ?? engineFromViewer(viewer) ?? null; + this.engine = engine ?? null; this._activityListeners = new Set(); this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } this._listeners = new Set(); @@ -214,7 +213,7 @@ export class LayerLifecycle { bindCatalog(viewer, engine, catalog, options = {}) { if (this._registrationsFinalized) throw new Error('Scene already attached'); this.viewer = viewer; - this.engine = engine ?? engineFromViewer(viewer) ?? null; + this.engine = engine ?? null; this._catalogSourceAvailability = catalog.getSourceAvailability; const scene = new AbortController(); this._sceneController = scene; @@ -1456,7 +1455,9 @@ export class LayerLifecycle { const entry = this.layers.get(layerId); if (!entry) return { intentEpoch: null, promise: Promise.resolve() }; const desiredState = Boolean(shouldEnable); - if (!desiredState) cancelCameraArrival(this.viewer); + // A layer leaving ends the camera arrival correction on a globe with a + // viewer (Cesium). + if (!desiredState && this.viewer) cancelCameraArrival(this.engine); if (entry.destroying) { return { intentEpoch: null, @@ -2487,7 +2488,7 @@ export class LayerLifecycle { async destroyLayer(layerId) { const entry = this.layers.get(layerId); if (!entry || entry.destroying) return false; - cancelCameraArrival(this.viewer); + if (this.viewer) cancelCameraArrival(this.engine); entry.destroying = true; // Work parked for the next scene ends now; teardown waits for its queue. this._settleSceneWaiters(false, entry); diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 9c533c4e8..b05c76fba 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -46,6 +46,7 @@ import { presentAwarenessDistance, } from '../layers/awareness/panel.js'; import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js'; +import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; const militaryAwarenessSource = readLayerSource( new URL('./militaryAwareness.js', import.meta.url), @@ -938,6 +939,7 @@ test('Contacts activation adopts the production military tracked-subject descrip try { _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: id, entity: null, billboard: { position, show: true }, @@ -1020,6 +1022,7 @@ test('Contacts activation adopts the production military tracked-subject descrip } finally { restores.reverse().forEach((restore) => restore()); _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: id, entity: null, billboard: { position, show: true }, @@ -1047,6 +1050,7 @@ test('production military tracked-subject label falls back to registration befor try { _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: id, entity: null, billboard: { position, show: true }, @@ -1100,6 +1104,7 @@ test('production military tracked-subject label falls back to registration befor } finally { restores.reverse().forEach((restore) => restore()); _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: id, entity: null, billboard: { position, show: true }, @@ -1146,6 +1151,7 @@ test('a cached Context subject re-reads its label when enrichment lands after se try { _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: id, entity: null, billboard: { position, color: Cesium.Color.WHITE, show: true }, @@ -1234,6 +1240,7 @@ test('a cached Context subject re-reads its label when enrichment lands after se } finally { restores.reverse().forEach((restore) => restore()); _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: id, entity: null, billboard: { position, show: true }, @@ -1256,6 +1263,7 @@ test('Contacts activation adopts the production civilian tracked-subject descrip try { _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: id, entity: null, billboard: { position, show: true }, @@ -1345,6 +1353,7 @@ test('Contacts activation adopts the production civilian tracked-subject descrip } finally { restores.reverse().forEach((restore) => restore()); _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: id, entity: null, billboard: { position, show: true }, diff --git a/src/data/militaryFlights.edgeCases.test.mjs b/src/data/militaryFlights.edgeCases.test.mjs index 62a9ba6de..8bce5888e 100644 --- a/src/data/militaryFlights.edgeCases.test.mjs +++ b/src/data/militaryFlights.edgeCases.test.mjs @@ -16,6 +16,7 @@ import { registerMilitaryIcaos, setMilitaryLayerActive, } from './militaryRegistry.js'; +import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; const SHARED_HEX = 'ae5e01'; const CIVIL_HEX = 'a0c1v1'; @@ -84,6 +85,7 @@ test('edge:military: an ICAO in both feeds draws one target and keeps the milita const civilBillboards = collection(); _armFlightTrackingRestoreForTest(null); _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: 'seedm0', entity: null, billboard: { show: false, position: Cesium.Cartesian3.ZERO }, @@ -93,6 +95,7 @@ test('edge:military: an ICAO in both feeds draws one target and keeps the milita tracked: false, }); _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: 'seedf0', entity: null, billboard: { show: false, position: Cesium.Cartesian3.ZERO }, @@ -171,6 +174,7 @@ test('edge:military: losing the tracked contact from the owning feed releases th trackedEntity: entity, }; _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: SHARED_HEX, entity, billboard: { diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 174bf8bfd..604cd1091 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -17,6 +17,8 @@ import { destroyTrackedReadout, initTrackedReadout, } from './trackedReadout.js'; +import { trackingEngine } from '../testSupport/viewerEngine.mjs'; +import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; test('share-Follow absence requires an accepted adsb.lol snapshot', async () => { _setMilitaryTrackingRefreshOutcomeForTest({ status: 'source-unavailable' }); @@ -146,6 +148,7 @@ test('military first update forwards caller cancellation into the feed request', test('nonempty adsb.lol payload with zero usable rows cannot prove share target absence', async () => { _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: 'ae1234', entity: { gevLabelModel: { title: 'WARM', details: [] } }, billboard: { show: false }, @@ -192,6 +195,7 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p const billboardCollection = { show: false, remove() {} }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24, entity, billboard, @@ -263,6 +267,7 @@ test('cached military snapshots retain their observation time and expose stale f const history = []; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: 'ae01ce', entity: { gevLabelModel: { title: 'RCH451', details: [] } }, billboard: { @@ -403,8 +408,9 @@ test('real military track path creates no native label and publishes every cache globalThis.fetch = async () => ({ ok: false }); _setTrackedOverlayHostForTest(host); try { - initTrackedReadout(viewer); + initTrackedReadout(trackingEngine(viewer)); _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24, entity: null, billboard, @@ -844,6 +850,7 @@ test('the military tracked descriptor publishes the reported vertical rate', () // every military contact as level flight. const seedRate = (verticalRateMps) => _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: 'ae7f01', entity: { gevLabelModel: { title: 'TEST', details: [] } }, billboard: { diff --git a/src/data/nearbyModelHandoff.test.mjs b/src/data/nearbyModelHandoff.test.mjs index 98b32341f..20567291b 100644 --- a/src/data/nearbyModelHandoff.test.mjs +++ b/src/data/nearbyModelHandoff.test.mjs @@ -12,6 +12,7 @@ import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import flightsLayer, { _setTrackedFlightRefreshStateForTest } from './flights.js'; import militaryFlightsLayer, { _setTrackedMilitaryRefreshStateForTest } from './militaryFlights.js'; +import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; const ICAO = 'abc123'; const OTHER = 'def456'; @@ -41,6 +42,7 @@ const LAYERS = [ visualCenterM: Cesium.Cartesian3.ZERO, seed({ billboardShow, models = [] }) { _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: ICAO, entity: null, billboard: contactBillboard(billboardShow), @@ -63,6 +65,7 @@ const LAYERS = [ visualCenterM: Cesium.Cartesian3.ZERO, seed({ billboardShow, models = [] }) { _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: ICAO, entity: null, billboard: contactBillboard(billboardShow), diff --git a/src/data/spriteOrder.test.mjs b/src/data/spriteOrder.test.mjs index 002d6c9f1..c61ea9cf8 100644 --- a/src/data/spriteOrder.test.mjs +++ b/src/data/spriteOrder.test.mjs @@ -109,17 +109,17 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r assert.deepEqual(calls, [viewer, viewer, viewer]); const firmsLayer = createFirmsHeatmapLayer({ id: 'firms', name: 'FIRMS' }); - // Flights draws through its renderer: enable asks the fleet to - // restore its order, which the Cesium aircraft renderer wires to the shared - // restorer under the flights' sprite slot. + // Flights draws through its display: enable asks the fleet to restore its + // order; the display's sprite sets take the flights' slot as their draw + // order on every engine (aircraftLayerDisplay). assert.match(flightsLayer.enable.toString(), /fleet\.restoreOrderOnEnable\(\)/); assert.match( - readFileSync(new URL('../layers/aircraft/render/cesium.js', import.meta.url), 'utf8'), - /restoreOrderOnEnable: \(\) => restoreSpriteOrderOnEnable\(spriteLayer, viewer\)/, + readFileSync(new URL('../layers/aircraft/render/display.js', import.meta.url), 'utf8'), + /order: SPRITE_LAYER_ORDER\.indexOf\(layerId\)/, ); assert.match( - readFileSync(new URL('../layers/flights/render/cesium.js', import.meta.url), 'utf8'), - /spriteLayer: 'flights'/, + readFileSync(new URL('../layers/flights/render/display.js', import.meta.url), 'utf8'), + /aircraftLayerDisplay\(\s*'flights',/, ); assert.match( firmsLayer.enable.toString(), diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs index 98fd534c4..55e97f785 100644 --- a/src/data/tr3bRegistry.test.mjs +++ b/src/data/tr3bRegistry.test.mjs @@ -33,6 +33,7 @@ import militaryFlightsLayer, { import { findCompatibleHistoryIndex } from './militaryAwareness.js'; import { createGevActionRunner } from '../voice/gevActions.js'; import { ANALYST_LAYERS, createAnalystEngine } from './analystEngine.js'; +import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; /** Strip block and line comments so source pins scan CODE, not prose. */ function stripComments(source) { @@ -161,6 +162,7 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke }; const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24, entity, billboard, @@ -260,7 +262,7 @@ test('both flight layers keep a converted contact 2D and visible (render invaria // 4. Orientation contract is untouched: still a screen-projected rotation // with alignedAxis ZERO, and no new per-frame CallbackProperty. - assert.match(source, /alignedAxis: Cesium\.Cartesian3\.ZERO/, `${name}: alignedAxis stays ZERO`); + assert.match(source, /alignedAxis: geo\.cartesian3\.ZERO/, `${name}: alignedAxis stays ZERO`); assert.doesNotMatch(source, /isTr3b[\s\S]{0,200}new Cesium\.CallbackProperty/, `${name}: the Easter egg adds no per-frame CallbackProperty`); } @@ -295,6 +297,7 @@ test('conversions are session-scoped and no lifecycle path clears them', async ( setTr3b(icao24, true); const viewer = { camera: { positionCartographic: null }, scene: {} }; _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24, entity: null, billboard: { @@ -459,6 +462,7 @@ test('cockpit class filter matches a converted contact end to end', async () => // 2) Real getNearby record for a real (converted) contact in the layer. const seed = () => _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24, entity: null, billboard: { @@ -505,6 +509,7 @@ test('military records and detection cards agree with the conversion', () => { const icao24 = 'ae01ce'; const center = Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 200); const seed = () => _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24, entity: null, billboard: { diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index ff862ae60..3d7b6cf25 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -46,6 +46,7 @@ import militaryFlightsLayer, { } from './militaryFlights.js'; import { clearTr3bRegistry, setTr3b } from './tr3bRegistry.js'; import { reportMeshFloorCell, _clearMeshFloorCellsForTest } from './groundFloor.js'; +import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; // Sample altitudes are expressed RELATIVE to the band rather than as absolute // offsets. The thresholds have already been retuned once (1_000_000 → 150_000 @@ -219,6 +220,7 @@ const LAYERS = [ seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) { const billboard = contactBillboard(); _setTrackedFlightRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24, entity: null, billboard, @@ -244,6 +246,7 @@ const LAYERS = [ seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) { const billboard = contactBillboard(); _setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24, entity: null, billboard, diff --git a/src/data/trackedReadout.js b/src/data/trackedReadout.js index e32355f28..71bcc7848 100644 --- a/src/data/trackedReadout.js +++ b/src/data/trackedReadout.js @@ -4,7 +4,6 @@ import { setOverlaySourceVisible, } from '../overlays/worldOverlay.js'; import { WORLD_OVERLAY_STYLE } from '../overlays/worldOverlayTokens.js'; -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; /** * @module trackedReadout @@ -34,9 +33,7 @@ const DEFAULT_TRACKED_OVERLAY_HOST = Object.freeze({ clearSource: clearOverlaySource, }); -let _viewer = null; -// The globe engine, when the readout was given one (or a viewer with a -// registered engine): the followed target is engine.camera.following(). +// The globe engine: the followed target is engine.camera.following(). let _engine = null; let _trackedEntityChangedRemove = null; let _selectedContext = null; @@ -149,8 +146,7 @@ function followSubject(handle) { } function trackedSubject() { - if (_engine) return followSubject(_engine.camera.following()); - return _viewer?.trackedEntity || null; + return _engine ? followSubject(_engine.camera.following()) : null; } function activeEntity() { @@ -262,22 +258,17 @@ const READOUT_CONTEXT_LAYERS = new Set(['alpr-cameras', 'local-adsb']); /** * Initialize the model bridge and selection listeners. No render listener is * installed; the already-initialized world-overlay host owns the frame lane. - * @param {Object} target The globe engine (or a viewer whose engine is - * registered): the readout follows engine.camera.following(), its label - * (plain data) or its Cesium entity. A bare viewer (tests) is read for - * its trackedEntity. + * @param {import('../globe/contract.js').GlobeEngine} engine The readout + * follows engine.camera.following(), its label (plain data) or its Cesium + * entity. */ -export function initTrackedReadout(target) { - if (!target || _viewer === target) return; - if (_viewer) destroyTrackedReadout(); - _viewer = target; - _engine = engineFromViewer(target); +export function initTrackedReadout(engine) { + if (!engine || _engine === engine) return; + if (_engine) destroyTrackedReadout(); + _engine = engine; _overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true); - const changed = _engine - ? _engine.camera.followChanged - : target.trackedEntityChanged; _trackedEntityChangedRemove = - changed?.addEventListener?.(() => { + engine.camera.followChanged?.addEventListener?.(() => { if (trackedSubject()) _selectedContext = null; syncActiveEntity(); }) || null; @@ -343,7 +334,6 @@ export function destroyTrackedReadout() { _selectedContext = null; clearTrackedSource(); _overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, false); - _viewer = null; _engine = null; } diff --git a/src/data/trackedReadout.test.mjs b/src/data/trackedReadout.test.mjs index f52332f94..b7583e363 100644 --- a/src/data/trackedReadout.test.mjs +++ b/src/data/trackedReadout.test.mjs @@ -1,4 +1,5 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; +import { trackingEngine } from '../testSupport/viewerEngine.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; @@ -157,7 +158,7 @@ test('tracked entity publishes a protected host entry backed by the frame cache' globalThis.window = fakeWindow; _setTrackedOverlayHostForTest(recorder.host); try { - initTrackedReadout(viewer); + initTrackedReadout(trackingEngine(viewer)); const publication = recorder.calls.find(({ op }) => op === 'set'); assert.ok(publication); assert.equal(publication.sourceId, 'tracked'); @@ -205,7 +206,7 @@ test('selection lifecycle leaves installations to their own entry and retains AL globalThis.window = fakeWindow; _setTrackedOverlayHostForTest(recorder.host); try { - initTrackedReadout(viewer); + initTrackedReadout(trackingEngine(viewer)); const setsBefore = recorder.calls.filter(({ op }) => op === 'set').length; fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: { layerId: 'ais-live-vessels', entity: installation }, diff --git a/src/globe/cesium/follow.js b/src/globe/cesium/follow.js index 3e6230b3d..81c2097c5 100644 --- a/src/globe/cesium/follow.js +++ b/src/globe/cesium/follow.js @@ -1,7 +1,8 @@ // camera.follow on Cesium: the viewer's trackedEntity (Cesium's EntityView // owns the continuous follow, the user turns and zooms around the target). -// With `native` the layer's own Entity is tracked; without it an internal -// proxy Entity whose position is the caller's `position()`. Each follow gets +// A `native` Entity is tracked itself; otherwise an internal proxy Entity +// (ENU frame) whose position is the caller's `position()`, which shows a +// non-Entity native's tracked-readout fields. Each follow gets // one exact tracked frame on preUpdate (applyTrackedCameraFrame) and the // zoom limits that keep the camera outside the minimum range. // following() also reports a trackedEntity the app set itself, and @@ -21,6 +22,15 @@ import { toCesium3 } from './convert.js'; const MAX_FRAME_ATTEMPTS = 120; const entityName = (entity) => (entity ? entity.name || entity.id : undefined); +// What viewer.trackedEntity readers take from a tracked Entity, read through +// a proxy from the layer's own handle. +const READOUT_FIELDS = [ + 'gevTrackedId', + 'gevLabelModel', + 'gevDisplayPosition', + 'gevVisualPosition', + 'gevSelectionOrigin', +]; const ZOOM_INERTIA_STATES = new WeakMap(); function acquireStableTrackedZoom(controller, owner, minRange) { @@ -257,7 +267,8 @@ export function createCesiumFollow(viewer, { commanded }) { function start(state, options) { state.options = options; - let entity = options.native; + const { native } = options; + let entity = native instanceof Cesium.Entity ? native : null; if (!entity) { const scratch = new Cesium.Cartesian3(); entity = state.proxy = viewer.entities.add({ @@ -273,6 +284,7 @@ export function createCesiumFollow(viewer, { commanded }) { : undefined; }, false), viewFrom: toCesium3(state.options.viewFrom()), + trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, // Cesium's EntityView moves the camera only once the tracked // entity has a bounding sphere: a 1 px transparent point gives it // one (an entity without graphics is framed once, then left). @@ -280,14 +292,24 @@ export function createCesiumFollow(viewer, { commanded }) { }); // Picks skip it (picker.js). entity.gevFollowProxy = true; + if (native) + for (const field of READOUT_FIELDS) + Object.defineProperty(entity, field, { + get: () => native[field], + set: (value) => { + native[field] = value; + }, + }); } state.entity = entity; - state.handle.native = options.native; + state.handle.native = native; state.handle.label = options.label; viewer.camera.cancelFlight(); viewer.trackedEntity = entity; const viewFrom = - options.native?.viewFrom ?? toCesium3(state.options.viewFrom()); + entity === native && native.viewFrom + ? native.viewFrom + : toCesium3(state.options.viewFrom()); state.stopFrame = applyTrackedCameraFrame(viewer, entity, viewFrom, { minRange: options.minRange, diff --git a/src/globe/cesium/follow.test.mjs b/src/globe/cesium/follow.test.mjs index e7c0372c0..6d1f3f40e 100644 --- a/src/globe/cesium/follow.test.mjs +++ b/src/globe/cesium/follow.test.mjs @@ -77,6 +77,38 @@ test('without native the follow tracks a proxy entity at position()', () => { assert.equal(viewer.trackedEntity, undefined); }); +test('a native that is not an Entity is echoed and followed through an ENU proxy that shows its readout fields', () => { + const { viewer, camera, frame } = setup(); + const native = { + id: 'flights-tracked-1', + gevTrackedId: 'flights:abc123', + gevLabelModel: { title: 'DAL123' }, + gevDisplayPosition: () => TARGET, + }; + const handle = camera.follow({ + native, + position: () => TARGET, + viewFrom: () => ({ x: 0, y: -500, z: 200 }), + }); + const proxy = viewer.trackedEntity; + assert.ok(proxy instanceof Cesium.Entity); + assert.notEqual(proxy, native); + assert.equal(handle.native, native); + assert.equal(camera.following(), handle); + assert.equal(proxy.trackingReferenceFrame, Cesium.TrackingReferenceFrame.ENU); + assert.equal(proxy.gevTrackedId, 'flights:abc123'); + native.gevLabelModel = { title: 'DAL124' }; + assert.equal(proxy.gevLabelModel.title, 'DAL124', 'read through'); + assert.equal(proxy.gevDisplayPosition(), TARGET); + frame(); + const [[, offset]] = viewer + .callsOf('camera.lookAtTransform') + .map((call) => call.args); + assert.deepEqual([offset.x, offset.y, offset.z], [0, -500, 200]); + handle.stop(); + assert.equal(viewer.entities.contains(proxy), false); +}); + test('a camera command or another tracked entity ends the follow', () => { const { viewer, camera, changes } = setup(); const options = { position: () => TARGET, viewFrom: { x: 0, y: -500, z: 0 } }; diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js index 64805b3e9..8fc73a85b 100644 --- a/src/globe/cesium/movers.js +++ b/src/globe/cesium/movers.js @@ -3,10 +3,11 @@ // sprites are a BillboardCollection (screen-aligned, rotation in screen // space, an image per atlas cell cropped from the set's atlas), points a // PointPrimitiveCollection, models one Model per instance in a -// PrimitiveCollection. commit() writes the changed instances and requests a -// render; nothing else renders. Picks resolve through identify(): the -// billboard, point or model a Cesium pick names → its set, instance and -// the pickId the layer gave it. +// PrimitiveCollection (the glTF's own axes, no axis correction, lit without +// Cesium's dynamic environment map). commit() writes the changed instances +// and requests a render; nothing else renders. Picks resolve through +// identify(): the billboard, point or model a Cesium pick names → its set, +// instance and the pickId the layer gave it. import * as Cesium from 'cesium'; import { horizonVisible } from '../cameraMath.js'; @@ -246,6 +247,15 @@ function modelPart(options, store, { factories, requestRender, owners }) { factories .loadModel({ url: model.url, + // Resources built at once, not spread over frames behind tile jobs. + asynchronous: false, + // The instance orientation turns the glTF's own axes (the contract's + // model frame), as Navara draws them. + upAxis: Cesium.Axis.Z, + forwardAxis: Cesium.Axis.X, + // Navara has no environment map; Cesium's renders the sky around + // each model and reads it back from the GPU. + environmentMapOptions: { enabled: false }, minimumPixelSize: model.minimumPixelSize, ...modelBlend(model.colorBlend), allowPicking: pickable, @@ -274,10 +284,12 @@ function modelPart(options, store, { factories, requestRender, owners }) { }; return { primitive: collection, - // Every drawn instance's model has loaded. - ready() { - for (let index = 0; index < store.count; index++) - if (!models[index]?.ready) return false; + // Every drawn instance's model has loaded (with an index, that one's). + ready(index) { + if (index !== undefined) + return index < store.count && models[index]?.ready === true; + for (let at = 0; at < store.count; at++) + if (!models[at]?.ready) return false; return true; }, commit(range) { diff --git a/src/globe/cesium/movers.test.mjs b/src/globe/cesium/movers.test.mjs index 1262671af..1d355f972 100644 --- a/src/globe/cesium/movers.test.mjs +++ b/src/globe/cesium/movers.test.mjs @@ -301,6 +301,22 @@ test('models load per instance with the set options and follow position, orienta assert.equal(options.colorBlendAmount, 0.4); assert.equal(options.allowPicking, true); assert.equal(options.customShader, undefined, 'lit by default'); + assert.equal(options.asynchronous, false, 'built at once'); + // The glTF's own axes: no Cesium axis correction (Y_UP_TO_Z_UP × + // Z_UP_TO_X_UP), so an orientation draws the same on every engine. + assert.equal(options.upAxis, Cesium.Axis.Z); + assert.equal(options.forwardAxis, Cesium.Axis.X); + assert.ok( + Cesium.Matrix4.equals( + Cesium.ModelUtility.getAxisCorrectionMatrix( + options.upAxis, + options.forwardAxis, + new Cesium.Matrix4(), + ), + Cesium.Matrix4.IDENTITY, + ), + ); + assert.deepEqual(options.environmentMapOptions, { enabled: false }); const [first, second] = items(collection); assert.notEqual(first.modelMatrix, second.modelMatrix); const translation = Cesium.Matrix4.getTranslation( @@ -316,8 +332,12 @@ test('models load per instance with the set options and follow position, orienta assert.equal(second.id, 'second'); assert.equal(set.ready(), false, 'loaded models not yet ready'); first.ready = true; + assert.equal(set.ready(0), true, 'an instance is ready with its model'); + assert.equal(set.ready(1), false); + assert.equal(set.ready(), false); second.ready = true; assert.equal(set.ready(), true); + assert.equal(set.ready(2), false, 'past the count'); // Shrinking hides the instance past the count; a later write moves it. set.setCount(1); set.commit(); diff --git a/src/globe/contract.js b/src/globe/contract.js index 7d82aa497..5f39b191b 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -141,8 +141,11 @@ * from the target in its east-north-up frame, metres. * @property {number} [minRange] Closest the camera comes (150 m). * @property {'enu'} [frame] - * @property {any} [native] Cesium only (layers' render/cesium.js): the - * Entity to put on viewer.trackedEntity instead of a proxy. + * @property {any} [native] What is followed, as the handle's `native`. On + * Cesium an Entity goes on viewer.trackedEntity itself; anything else (a + * layer's own handle) is followed through a proxy Entity, which shows the + * handle's tracked-readout fields (gevTrackedId, gevLabelModel, + * gevDisplayPosition, gevVisualPosition, gevSelectionOrigin). * @property {{ id: string, model: object | (() => object), position: () => Cartesian3 }} [label] * The tracked readout's card (src/data/trackedReadout.js): its id, label * model ({ title, details, accent, ... }) and anchor, as plain data. @@ -401,7 +404,8 @@ * @property {number} [rotation] Sprites: screen angle in radians, * counter-clockwise from screen up (Cesium's Billboard.rotation). * @property {{ x: number, y: number, z: number, w: number }} [orientation] - * Models: the model frame's rotation into Earth-fixed axes. + * Models: the rotation of the glTF's own axes (as the file stores them, + * +Y up) into Earth-fixed axes; no engine adds an axis correction. * @property {number[]} [color] rgba in 0..1; multiplies the sprite's texels. * @property {number} [scale] * @property {string} [cell] An atlas cell key. @@ -415,9 +419,10 @@ * @property {() => number} count * @property {() => void} commit Uploads what changed (one buffer write) and * requests a render; nothing else draws a frame. - * @property {() => boolean} ready Whether what the set draws has loaded: a - * model set's glTF (Cesium: every drawn instance's model), a sprite set's - * atlas. + * @property {(index?: number) => boolean} ready Whether what the set draws + * has loaded: a model set's glTF (Cesium: every drawn instance's model), a + * 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 {() => void} destroy * diff --git a/src/globe/movers.js b/src/globe/movers.js index 4ca357382..95adb669a 100644 --- a/src/globe/movers.js +++ b/src/globe/movers.js @@ -348,8 +348,9 @@ export function createMoverSet( commit(); store.clean(); }, - // Whether what it draws has loaded (a model's glTF, an atlas image). - ready: () => !destroyed && ready() === true, + // 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, destroy() { if (destroyed) return; destroyed = true; @@ -385,7 +386,7 @@ export function createMoverRegistry(build) { sets.delete(options.id); part.destroy(); }, - ready: () => (part.ready ? part.ready() : true), + ready: (index) => (part.ready ? part.ready(index) : true), }); sets.set(options.id, entry); return entry.set; diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 0ac17312f..c4fed0b70 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -478,6 +478,11 @@ export function createContractSuite( set.setCount(3); set.commit(); ensure(set.count() === 3, `count ${set.count()}`); + ensure( + typeof set.ready() === 'boolean' && + typeof set.ready(0) === 'boolean', + 'ready() and ready(index) answer booleans', + ); ensure( engine.movers .list() @@ -1211,6 +1216,10 @@ export function createContractSuite( // ready() turns true once the glTF has loaded (the magenta point // and sprites above can already fill the region before then). await until(() => models.ready(), 'the model set ready()'); + ensure( + models.ready(0) === true, + 'the drawn instance is not ready(0)', + ); await until(() => modelPixels() > 150, 'the model drawn'); } finally { for (const set of sets) set.destroy(); @@ -2548,7 +2557,10 @@ export function createContractSuite( orientation: { heading: 0, pitch: -0.6, roll: 0 }, }); await settle(); + // A layer's own handle as `native`: echoed, whatever the engine. + const followed = { id: 'contract-followed' }; const handle = camera.follow({ + native: followed, position: () => at(step), viewFrom: { x: 0, y: -1000, z: 400 }, }); @@ -2556,6 +2568,7 @@ export function createContractSuite( camera.following() === handle, 'following() is not the handle', ); + ensure(handle.native === followed, 'the handle lost its native'); ensure( changes.at(-1) === handle, 'followChanged did not report the handle', diff --git a/src/hud.js b/src/hud.js index b398348c6..b214eb977 100644 --- a/src/hud.js +++ b/src/hud.js @@ -20,7 +20,6 @@ import { applicationServices } from './services/application.js'; */ import * as geo from './geo/index.js'; -import { engineFromViewer } from './globe/cesium/engineFromViewer.js'; import { greatCircleKm } from './geoDistance.js'; import { forward as toMGRS } from 'mgrs'; import { CITY_POIS } from './cityPois.js'; @@ -1236,7 +1235,7 @@ export class IntelHUD { this._removeCameraMoveStart?.(); this._removeCameraMoveStart = null; this._summaryRequest?.abort(); - this.engine = engine ? engineFromViewer(engine) : null; + this.engine = engine ?? null; this.viewer = viewer; if (!this.engine) return; this._removeCameraMoveStart = diff --git a/src/layers/aircraft/render/cesium.js b/src/layers/aircraft/render/cesium.js deleted file mode 100644 index de8e0927a..000000000 --- a/src/layers/aircraft/render/cesium.js +++ /dev/null @@ -1,425 +0,0 @@ -import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../../data/aircraftModelLighting.js'; -// The aircraft renderer on Cesium (contract: ./contract.js): the Cesium -// objects the aircraft layers draw with — the fleet BillboardCollection and its sprite-order slot, the model -// PrimitiveCollection and its glTF loads, the tracked Entity (billboard with -// per-frame CallbackProperties, followed through viewer.trackedEntity and -// the one exact tracked frame), the trail entity and its live head -// segment, and the camera/frame reads. Nothing here decides anything: the -// layer's logic decides, this draws. - -import * as Cesium from 'cesium'; -import { createTrail } from '../../../data/trailRenderer.js'; -import { - registerSpriteCollection, - restoreSpriteOrder, - restoreSpriteOrderOnEnable, -} from '../../../data/spriteOrder.js'; -import { horizonOccluder } from '../../../data/iconOrientation.js'; -import { engineFromViewer } from '../../../globe/cesium/engineFromViewer.js'; -import { applyTrackedCameraFrame } from '../../../globe/cesium/follow.js'; - -// The model shader of the IR boost: boosted -// models render UNLIT so a side-lit airframe does not go black in FLIR/NVG. -// Assigning a customShader to a ready model is a silent no-op, so the layer -// reloads boosted models and the shader is set at creation. One stateless -// instance, shared. -let unlitShader = null; -const irUnlitShader = () => - (unlitShader ??= new Cesium.CustomShader({ - lightingModel: Cesium.LightingModel.UNLIT, - })); - -// A colour from the layer (an Rgba or a Cesium.Color) as a Cesium.Color, for -// the entity properties that keep what they are given. Constants convert -// once. -const cesiumColors = new WeakMap(); -function cesiumColor(color) { - if (color instanceof Cesium.Color) return color; - let converted = cesiumColors.get(color); - if (!converted) { - converted = new Cesium.Color( - color.red, - color.green, - color.blue, - color.alpha, - ); - if (Object.isFrozen(color)) cesiumColors.set(color, converted); - } - return converted; -} - -const cesiumNearFar = (scalar) => - scalar instanceof Cesium.NearFarScalar - ? scalar - : new Cesium.NearFarScalar( - scalar.near, - scalar.nearValue, - scalar.far, - scalar.farValue, - ); - -/** - * @param {object} options - * @param {Cesium.Viewer} options.viewer - * @param {object} [options.engine] The globe engine (default: the viewer's). - * @param {string} options.spriteLayer The fleet's slot in data/spriteOrder. - * @param {number} [options.headingOffsetDeg=0] Model nose vs Cesium heading 0. - * @param {{ billboardCollection?: object, modelCollection?: object }} [options.adopt] - * Drive existing collections instead of creating them (test fakes; a - * layer that keeps its own model collection, local ADS-B). - * @param {(options: object) => Promise} [options.loadModel] The glTF - * loader (test seam; Cesium.Model.fromGltfAsync). - * @returns {import('./contract.js').AircraftRenderer} - */ -export function createAircraftCesiumRenderer({ - viewer, - engine = null, - spriteLayer, - headingOffsetDeg = 0, - adopt = null, - loadModel = (options) => Cesium.Model.fromGltfAsync(options), -}) { - let billboards = adopt?.billboardCollection ?? null; - let models = adopt?.modelCollection ?? null; - const scratchHpr = new Cesium.HeadingPitchRoll(0, 0, 0); - const scratchSphere = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 1.0); - // The frame's culling volume and one tester over it (no closure per call). - let cullingVolume = null; - const inFrustum = (position) => { - Cesium.Cartesian3.clone(position, scratchSphere.center); - return ( - cullingVolume.computeVisibility(scratchSphere) !== - Cesium.Intersect.OUTSIDE - ); - }; - const scratchSize = { - width: undefined, - height: undefined, - scale: undefined, - scaleByDistance: undefined, - }; - - const scene = () => viewer?.scene; - const camera = () => viewer?.camera; - - const view = { - get camera() { - return camera(); - }, - get scene() { - return scene(); - }, - get surfaceOwner() { - return viewer; - }, - get surfaceScene() { - return viewer?.scene; - }, - frameNumber: () => viewer?.scene?.frameState?.frameNumber ?? -1, - viewportHeightPx: () => viewer.scene.canvas.clientHeight, - // The tester stays valid until the next frustumTester() call. - frustumTester() { - const cam = viewer.camera; - cullingVolume = cam.frustum.computeCullingVolume( - cam.positionWC, - cam.directionWC, - cam.upWC, - ); - return inFrustum; - }, - horizon: () => horizonOccluder(viewer.camera), - worldToWindow: (position, result) => - Cesium.SceneTransforms.worldToWindowCoordinates( - viewer.scene, - position, - result, - ), - requestRender: () => viewer?.scene?.requestRender?.(), - on(event, listener) { - const source = - event === 'moveEnd' - ? viewer?.camera?.moveEnd - : event === 'preRender' - ? viewer?.scene?.preRender - : event === 'preUpdate' - ? viewer?.scene?.preUpdate - : null; - return source ? source.addEventListener(listener) : null; - }, - isDestroyed: () => !viewer || viewer.isDestroyed?.() === true, - // Evidence capture (qa-focus-evidence): Cesium's own render loop off, - // then its pending-loop latch closed so manual renders start at once. - holdFrames() { - viewer.useDefaultRenderLoop = false; - }, - closeFrameLatch() { - if (viewer?._cesiumWidget) - viewer._cesiumWidget._renderLoopRunning = false; - }, - releaseFrames() { - if (viewer) viewer.useDefaultRenderLoop = true; - }, - }; - - const fleet = { - attach() { - billboards = new Cesium.BillboardCollection(); - viewer.scene.primitives.add(billboards); - registerSpriteCollection(spriteLayer, billboards); - models = new Cesium.PrimitiveCollection(); - viewer.scene.primitives.add(models); - }, - // `owner`: the viewer the layer's destroy() was given (the same one). - detach(owner) { - const host = owner ?? viewer; - if (billboards) { - host.scene.primitives.remove(billboards); - billboards = null; - } - if (models) { - host.scene.primitives.remove(models); // removing destroys it + its models - models = null; - } - }, - attached: () => !!billboards, - visible: () => !!billboards && billboards.show, - setVisible(visible) { - if (billboards) billboards.show = visible; - }, - add: (spec) => billboards.add(spec), - remove: (sprite) => billboards.remove(sprite), - restoreOrder: () => restoreSpriteOrder(viewer), - restoreOrderOnEnable: () => restoreSpriteOrderOnEnable(spriteLayer, viewer), - }; - - const modelSet = { - live: () => !!models && !models.isDestroyed(), - setVisible(visible) { - if (models) models.show = visible; - }, - loadFleet: ({ - url, - minimumPixelSize, - scale, - color, - blendAmount, - unlit, - id, - }) => - loadModel({ - url, - asynchronous: false, - environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, - minimumPixelSize, - scale, - color, - colorBlendMode: Cesium.ColorBlendMode.MIX, - colorBlendAmount: blendAmount, - customShader: unlit ? irUnlitShader() : undefined, - id, // so scene.pick returns the icao for click-to-track - }), - loadTracked: ({ - url, - minimumPixelSize, - scale, - color, - blendAmount, - unlit, - id, - }) => - loadModel({ - url, - asynchronous: false, - environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, - minimumPixelSize, - scale, - color, - colorBlendMode: Cesium.ColorBlendMode.MIX, - colorBlendAmount: blendAmount, - customShader: unlit ? irUnlitShader() : undefined, - // Pick id (H1): clicking the tracked plane's own model must read as - // that plane, not as empty space. - id, - }), - // Warms Cesium's glTF cache (the retained instance is never drawn). - preload: (url) => - loadModel({ - url, - asynchronous: false, - }), - add: (model) => models.add(model), - remove(model) { - if (models && !models.isDestroyed()) { - try { - models.remove(model); - } catch { - /* gone */ - } - } - }, - destroy(model) { - try { - model.destroy(); - } catch { - /* gone */ - } - }, - /** - * World transform from a position and a course (pitch/roll 0, ENU frame), - * written into the model's OWN modelMatrix: Model.modelMatrix is a plain - * field Cesium clones per frame, so models never share one mutable - * matrix (a shared scratch stacked every model on the last-written - * plane: the "flickering like mad" bug). `offsetDeg`: this model's nose - * offset when the layer's models differ (default: the layer's). - */ - setPose(model, position, headingDeg, offsetDeg = headingOffsetDeg) { - scratchHpr.heading = Cesium.Math.toRadians((headingDeg || 0) + offsetDeg); - scratchHpr.pitch = 0; - scratchHpr.roll = 0; - return Cesium.Transforms.headingPitchRollToFixedFrame( - position, - scratchHpr, - Cesium.Ellipsoid.WGS84, - undefined, - model.modelMatrix, - ); - }, - }; - - const tracked = { - /** - * The tracked aircraft as an Entity: a pure billboard (no model or label - * graphic, so viewer.trackedEntity always has a ready bounding sphere), - * in the ENU tracking frame the close-range camera guard also uses. - * Cesium generates its id: an explicit one would throw when a re-init - * briefly overlaps collections. - */ - create(spec) { - return viewer.entities.add({ - position: new Cesium.CallbackProperty(() => spec.position(), false), - trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, - billboard: { - image: spec.image, - width: spec.width, - height: spec.height, - scale: spec.scale, - color: new Cesium.CallbackProperty( - () => cesiumColor(spec.color()), - false, - ), - sizeInMeters: false, - scaleByDistance: cesiumNearFar(spec.scaleByDistance), - alignedAxis: Cesium.Cartesian3.ZERO, - disableDepthTestDistance: spec.disableDepthTestDistance, - rotation: new Cesium.CallbackProperty(() => spec.rotation(), false), - }, - }); - }, - remove(handle) { - viewer.entities.remove(handle); - }, - isLive: (handle) => !!viewer?.entities?.contains?.(handle), - setImage(handle, image) { - if (handle?.billboard) handle.billboard.image = image; - }, - setViewFrom(handle, offset) { - handle.viewFrom = new Cesium.Cartesian3(offset.x, offset.y, offset.z); - }, - /** - * The tracked billboard's drawn size inputs, at the viewer's time. The - * result is reused (read it before the next call). - */ - billboardSize(handle) { - const billboard = handle?.billboard; - if (!billboard) return null; - const time = viewer.clock.currentTime; - scratchSize.width = billboard.width?.getValue(time); - scratchSize.height = billboard.height?.getValue(time); - scratchSize.scale = billboard.scale?.getValue(time); - scratchSize.scaleByDistance = billboard.scaleByDistance?.getValue(time); - return scratchSize; - }, - }; - - const follow = { - // Cancel any flight first: Cesium applies the entity's viewFrom on the - // first frame only from an idle camera. - start(handle) { - viewer.camera.cancelFlight(); - viewer.trackedEntity = handle; - return applyTrackedCameraFrame(viewer, handle, handle.viewFrom) || null; - }, - refocus(handle) { - viewer.camera.cancelFlight(); - viewer.trackedEntity = handle; - return applyTrackedCameraFrame(viewer, handle, handle.viewFrom) || null; - }, - // Releasing trackedEntity leaves the camera where the follow put it. - release() { - if (viewer) viewer.trackedEntity = undefined; - }, - isFollowing: (handle) => !!viewer && viewer.trackedEntity === handle, - current: () => viewer?.trackedEntity, - onChanged: (listener) => - viewer.trackedEntityChanged.addEventListener(listener), - }; - - const trails = { - create: ({ color, width }) => createTrail(viewer, { color, width }), - /** - * The live head segment: last fix → the dead-reckoned aircraft, updated - * per frame through a CallbackProperty (entity polylines update cheaply, - * the trail body rebuilds on setPositions). The 'gev-trail' id namespace - * is claimed by trailRenderer's pick owner. - */ - createHead({ id, show, positions, color, width }) { - if (!viewer) return null; - return viewer.entities.add({ - id, - show, - polyline: { - positions: new Cesium.CallbackProperty(() => positions(), false), - width, - material: Cesium.Color.fromCssColorString(color).withAlpha(0.9), - // The head never vanishes into the mesh (dimmed when occluded). - depthFailMaterial: - Cesium.Color.fromCssColorString(color).withAlpha(0.45), - arcType: Cesium.ArcType.GEODESIC, - }, - }); - }, - removeHead(head) { - if (head && viewer && !viewer.isDestroyed()) { - try { - viewer.entities.remove(head); - } catch { - /* already gone */ - } - } - }, - }; - - return { - kind: 'cesium', - get engine() { - return engine ?? engineFromViewer(viewer); - }, - viewer, - view, - fleet, - models: modelSet, - tracked, - follow, - trails, - /** - * Everything a vertical surface probe must not hit: Cesium excludes - * picked objects, and the layer's billboards and models are its own. - */ - surfaceExclusions: ({ sprites = [], models: modelHandles = [] }) => - [...sprites, ...modelHandles].filter(Boolean), - destroy() { - billboards = null; - models = null; - }, - }; -} diff --git a/src/layers/aircraft/render/cesium.test.mjs b/src/layers/aircraft/render/cesium.test.mjs deleted file mode 100644 index adb333137..000000000 --- a/src/layers/aircraft/render/cesium.test.mjs +++ /dev/null @@ -1,324 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; -import * as geo from '../../../geo/index.js'; -import { createAircraftCesiumRenderer } from './cesium.js'; -import { CYAN, nearFarScale, rgbaFromCss } from '../../shared/color.js'; - -/** A viewer with what the renderer touches: primitives, entities, camera. */ -function stubViewer() { - const added = []; - const removed = []; - const camera = { - positionWC: Cesium.Cartesian3.fromDegrees(-73.78, 40.0, 50_000), - directionWC: Cesium.Cartesian3.normalize( - Cesium.Cartesian3.negate( - Cesium.Cartesian3.fromDegrees(-73.78, 40.0, 50_000), - new Cesium.Cartesian3(), - ), - new Cesium.Cartesian3(), - ), - upWC: Cesium.Cartesian3.UNIT_Z, - frustum: new Cesium.PerspectiveFrustum({ fov: 1, aspectRatio: 1.5 }), - moveEnd: new Cesium.Event(), - cancelled: 0, - cancelFlight() { - this.cancelled += 1; - }, - }; - camera.frustum.near = 1; - camera.frustum.far = 1e8; - const viewer = { - camera, - scene: { - primitives: { - add: (p) => added.push(p) && p, - remove: (p) => removed.push(p), - }, - preRender: new Cesium.Event(), - preUpdate: new Cesium.Event(), - canvas: { clientHeight: 800 }, - frameState: { frameNumber: 42 }, - screenSpaceCameraController: {}, - requestRender() {}, - }, - entities: new Cesium.EntityCollection(), - clock: { currentTime: Cesium.JulianDate.now() }, - trackedEntity: undefined, - trackedEntityChanged: new Cesium.Event(), - isDestroyed: () => false, - }; - return { viewer, added, removed }; -} - -const renderer = (viewer, options = {}) => - createAircraftCesiumRenderer({ - viewer, - spriteLayer: 'flights', - headingOffsetDeg: 180, - ...options, - }); - -test('the Cesium aircraft renderer reports its kind', () => { - const { viewer } = stubViewer(); - assert.equal(renderer(viewer).kind, 'cesium'); -}); - -test('the fleet is one BillboardCollection and one model collection, in that order', () => { - const { viewer, added, removed } = stubViewer(); - const r = renderer(viewer); - assert.equal(r.fleet.attached(), false); - r.fleet.attach(); - assert.ok(added[0] instanceof Cesium.BillboardCollection); - assert.ok(added[1] instanceof Cesium.PrimitiveCollection); - assert.equal(r.fleet.visible(), true); - const sprite = r.fleet.add({ - position: geo.cartesian3.fromDegrees(0, 0, 1000), - color: rgbaFromCss('#FFB800').withAlpha(0.5), - scaleByDistance: nearFarScale(1000, 3, 8_000_000, 0.5), - id: 'abc123', - }); - assert.ok(sprite instanceof Cesium.Billboard); - assert.equal(sprite.id, 'abc123'); - r.fleet.setVisible(false); - assert.equal(r.fleet.visible(), false); - r.models.setVisible(false); - assert.equal(added[1].show, false); - assert.equal(r.models.live(), true); - r.fleet.detach(); - assert.deepEqual(removed, [added[0], added[1]]); - assert.equal(r.fleet.attached(), false); -}); - -test('a model pose is the old heading-pitch-roll frame with the nose offset', () => { - const { viewer } = stubViewer(); - const r = renderer(viewer); - const model = { modelMatrix: new Cesium.Matrix4() }; - const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000); - const written = r.models.setPose(model, position, 75); - assert.equal(written, model.modelMatrix, 'written into the model own matrix'); - const expected = Cesium.Transforms.headingPitchRollToFixedFrame( - position, - new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(75 + 180), 0, 0), - Cesium.Ellipsoid.WGS84, - ); - assert.deepEqual( - [...Cesium.Matrix4.toArray(model.modelMatrix)], - [...Cesium.Matrix4.toArray(expected)], - ); -}); - -test('a model pose takes a per-model nose offset (military per-class specs)', () => { - const { viewer } = stubViewer(); - const r = renderer(viewer); - const model = { modelMatrix: new Cesium.Matrix4() }; - const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000); - r.models.setPose(model, position, 75, 90); - const expected = Cesium.Transforms.headingPitchRollToFixedFrame( - position, - new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(75 + 90), 0, 0), - Cesium.Ellipsoid.WGS84, - ); - assert.deepEqual( - [...Cesium.Matrix4.toArray(model.modelMatrix)], - [...Cesium.Matrix4.toArray(expected)], - ); -}); - -test('model loads carry the layer spec; the IR boost loads an UNLIT model', async () => { - const { viewer } = stubViewer(); - const r = renderer(viewer); - const calls = []; - const realFrom = Cesium.Model.fromGltfAsync; - Cesium.Model.fromGltfAsync = (options) => { - calls.push(options); - return Promise.resolve({ options }); - }; - try { - const spec = { - url: '/models/airplane.glb', - minimumPixelSize: 24, - scale: 1, - color: CYAN, - blendAmount: 0.94, - unlit: false, - id: 'abc123', - }; - await r.models.loadFleet(spec); - await r.models.loadTracked({ ...spec, unlit: true, minimumPixelSize: 40 }); - await r.models.preload('/models/airplane.glb'); - } finally { - Cesium.Model.fromGltfAsync = realFrom; - } - assert.equal(calls.length, 3); - for (const call of calls) assert.equal(call.asynchronous, false); - assert.deepEqual(calls[0].environmentMapOptions, { enabled: false }); - assert.deepEqual(calls[1].environmentMapOptions, { enabled: false }); - assert.equal(calls[0].colorBlendMode, Cesium.ColorBlendMode.MIX); - assert.equal(calls[0].colorBlendAmount, 0.94); - assert.equal(calls[0].customShader, undefined); - assert.equal(calls[0].id, 'abc123'); - assert.equal(calls[1].minimumPixelSize, 40); - assert.equal(calls[1].customShader.lightingModel, Cesium.LightingModel.UNLIT); - assert.deepEqual(Object.keys(calls[2]).sort(), ['asynchronous', 'url']); -}); - -test('the frustum and horizon tests are the Cesium ones', () => { - const { viewer } = stubViewer(); - const r = renderer(viewer); - const inFrustum = r.view.frustumTester(); - const below = Cesium.Cartesian3.fromDegrees(-73.78, 40.0, 0); - const behind = Cesium.Cartesian3.fromDegrees(106.22, -40.0, 0); - const aboveCamera = Cesium.Cartesian3.fromDegrees(-73.78, 40.0, 200_000); - assert.equal(inFrustum(below), true); - assert.equal(inFrustum(aboveCamera), false); - assert.equal(r.view.horizon().isPointVisible(below), true); - assert.equal(r.view.horizon().isPointVisible(behind), false); - assert.equal(r.view.frameNumber(), 42); - assert.equal(r.view.viewportHeightPx(), 800); -}); - -test('the tracked aircraft is an ENU-framed billboard entity with live callbacks', () => { - const { viewer } = stubViewer(); - const r = renderer(viewer); - let position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000); - let color = CYAN; - const handle = r.tracked.create({ - position: () => position, - image: 'data:image/png;base64,', - width: 28, - height: 28, - scale: 1.2, - color: () => color, - scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - rotation: () => 0.25, - }); - assert.ok(handle instanceof Cesium.Entity); - assert.equal(r.tracked.isLive(handle), true); - const time = viewer.clock.currentTime; - assert.equal( - handle.trackingReferenceFrame, - Cesium.TrackingReferenceFrame.ENU, - ); - assert.equal(handle.position.getValue(time), position); - const billboardColor = handle.billboard.color.getValue(time); - assert.ok(billboardColor instanceof Cesium.Color); - assert.ok(billboardColor.equals(Cesium.Color.CYAN)); - color = CYAN.withAlpha(0); - assert.equal(handle.billboard.color.getValue(time).alpha, 0); - assert.equal(handle.billboard.rotation.getValue(time), 0.25); - assert.ok( - Cesium.Cartesian3.equals( - handle.billboard.alignedAxis.getValue(time), - Cesium.Cartesian3.ZERO, - ), - ); - assert.ok( - handle.billboard.scaleByDistance.getValue(time) instanceof - Cesium.NearFarScalar, - ); - assert.deepEqual(r.tracked.billboardSize(handle), { - width: 28, - height: 28, - scale: 1.2, - scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5), - }); - r.tracked.setViewFrom(handle, { x: 0, y: -2400, z: 1650 }); - assert.ok(handle.viewFrom.getValue(time) instanceof Cesium.Cartesian3); - position = geo.cartesian3.fromDegrees(-97.6, 30.2, 3000); - assert.equal(handle.position.getValue(time), position); - r.tracked.remove(handle); - assert.equal(r.tracked.isLive(handle), false); -}); - -test('follow tracks the handle after cancelling a flight; release leaves the camera', () => { - const { viewer } = stubViewer(); - const r = renderer(viewer); - const handle = r.tracked.create({ - position: () => geo.cartesian3.fromDegrees(-97.7, 30.2, 3000), - image: 'x', - width: 28, - height: 28, - scale: 1, - color: () => CYAN, - scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - rotation: () => 0, - }); - r.tracked.setViewFrom(handle, { x: 0, y: -2400, z: 1650 }); - const changes = []; - const remove = r.follow.onChanged(() => changes.push(r.follow.current())); - viewer.trackedEntityChanged.raiseEvent(); // the viewer raises on assignment - const stop = r.follow.start(handle); - assert.equal(viewer.camera.cancelled, 1); - assert.equal(viewer.trackedEntity, handle); - assert.equal(r.follow.isFollowing(handle), true); - assert.equal(typeof stop, 'function', 'the one exact tracked frame'); - stop(); - r.follow.release(); - assert.equal(viewer.trackedEntity, undefined); - assert.equal(r.follow.isFollowing(handle), false); - remove(); - assert.equal(changes.length, 1); -}); - -test('the trail head is a geodesic entity polyline in the trail namespace', () => { - const { viewer } = stubViewer(); - const r = renderer(viewer); - const ends = [ - geo.cartesian3.fromDegrees(0, 0, 1000), - geo.cartesian3.fromDegrees(0.1, 0, 1000), - ]; - const head = r.trails.createHead({ - id: 'gev-trail:fl-head-1', - show: true, - positions: () => ends, - color: '#00d4ff', - width: 2.5, - }); - const time = viewer.clock.currentTime; - assert.equal(head.id, 'gev-trail:fl-head-1'); - assert.equal(head.polyline.positions.getValue(time), ends); - assert.equal(head.polyline.arcType.getValue(time), Cesium.ArcType.GEODESIC); - const base = Cesium.Color.fromCssColorString('#00d4ff'); - assert.ok( - head.polyline.material.getValue(time).color.equals(base.withAlpha(0.9)), - ); - assert.ok( - head.polyline.depthFailMaterial - .getValue(time) - .color.equals(base.withAlpha(0.45)), - ); - r.trails.removeHead(head); - assert.equal(viewer.entities.contains(head), false); -}); - -test('an adopted (test) collection is driven as the fleet', () => { - const { viewer } = stubViewer(); - const removed = []; - const billboardCollection = { - show: true, - remove: (sprite) => removed.push(sprite), - }; - const modelCollection = { - added: [], - add(model) { - this.added.push(model); - }, - remove() {}, - isDestroyed: () => false, - }; - const r = renderer(viewer, { - adopt: { billboardCollection, modelCollection }, - }); - assert.equal(r.fleet.visible(), true); - r.fleet.remove('sprite'); - assert.deepEqual(removed, ['sprite']); - r.models.add('model'); - assert.deepEqual(modelCollection.added, ['model']); - assert.deepEqual( - r.surfaceExclusions({ sprites: ['a'], models: ['b', null] }), - ['a', 'b'], - ); -}); diff --git a/src/layers/aircraft/render/contract.js b/src/layers/aircraft/render/contract.js index 381dc4a07..8ad000ccd 100644 --- a/src/layers/aircraft/render/contract.js +++ b/src/layers/aircraft/render/contract.js @@ -1,14 +1,13 @@ -// The aircraft renderer: what the aircraft layers' logic (flights now, -// military and local ADS-B after it) asks of an engine to draw a fleet of -// aircraft, its 3D models, the tracked aircraft, its trail, and to follow it. -// render/cesium.js implements it over Cesium (the objects the layers used -// to build themselves, one for one); render/navara.js implements it on the -// Navara engine over engine.movers / engine.lines / engine.camera.follow. -// Nothing here imports an engine. +// The aircraft renderer: what the aircraft layers' logic (flights, military +// and local ADS-B) asks of an engine to draw a fleet of aircraft, its 3D +// models, the tracked aircraft, its trail, and to follow it. One +// implementation, render/display.js, draws it on every engine through the +// globe engine contract (engine.movers / engine.lines / +// engine.camera.follow). Nothing here imports an engine. // // The logic holds HANDLES and writes plain fields on them; the field names -// are Cesium's (the extraction is a pure move), and an engine other than -// Cesium implements them as setters: +// are Cesium's (the layers were written against its objects), and the +// display implements them as setters: // // SpriteHandle (fleet.add) — one aircraft glyph: // position (Cartesian3), show, rotation (screen angle, rad), @@ -19,9 +18,10 @@ // 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), scale, computedScale (read-only: scale after -// minimumPixelSize), color, id, modelMatrix (16 numbers, column-major, -// written by models.setPose and read for anchors), sceneGraph? (Cesium). +// show, ready (read-only: placed, and its model loaded), 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). // TrackedHandle (tracked.create) — the selected aircraft's glyph and the // follow target: viewFrom (ENU offset, read), and the annotation fields // the shell reads: gevSelectionOrigin, gevTrackedId, gevLabelModel, @@ -33,9 +33,9 @@ // camera-shaped (positionWC, positionCartographic, directionWC, upWC, // rightWC, heading, pitch, roll, frustum.fovy) and `scene` scene-shaped // ({ camera, canvas.clientHeight, frameState.frameNumber }) for the shared -// helpers that take them (iconOrientation, focusDeemphasis); on Navara -// they are adapters over engine.camera.pose(), with the engine registered -// for the scene (engineFromViewer) so projections go through its projector. +// helpers that take them (iconOrientation, focusDeemphasis): adapters over +// engine.camera.pose(), with the engine registered for the scene +// (engineFromViewer) so projections go through its projector. /** * @typedef {object} AircraftView @@ -51,10 +51,10 @@ * @property {() => void} requestRender * @property {(event: 'preRender'|'preUpdate'|'moveEnd', listener: Function) => () => void} on * @property {object} surfaceOwner What groundSnap/meshFloor sample through - * (a viewer registered with its engine, or the engine). + * (the engine). * @property {object} surfaceScene What sampleMeshFloorCells takes. * @property {() => void} holdFrames Evidence capture: stop the engine's own - * frame loop (QA only). + * frame loop (QA only; the contract has no frame hold, so it holds none). * @property {() => void} closeFrameLatch Evidence capture, one task later: * let manual renders start without waiting for the loop's next frame. * @property {() => void} releaseFrames @@ -62,9 +62,7 @@ /** * @typedef {object} AircraftRenderer - * @property {'cesium'|'navara'} kind - * @property {object|null} engine The globe engine (null in unit tests that - * drive a bare viewer). + * @property {object} engine The globe engine. * @property {AircraftView} view * @property {object} fleet attach(), detach(), attached(), visible(), * setVisible(bool), add(spec) → SpriteHandle, remove(sprite), @@ -73,18 +71,20 @@ * loadTracked(spec), preload(url) → 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, color, blendAmount, unlit, id }. + * spec: { url, minimumPixelSize, scale, radiusM, color, blendAmount, + * unlit, id }. * @property {object} tracked create(spec) → TrackedHandle, remove(handle), * isLive(handle), setImage(handle, image), setViewFrom(handle, enu), * 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, - * release(), isFollowing(handle), current(), onChanged(listener) → remove. + * release(), isFollowing(handle), current() (the tracked handle, or what + * another layer follows), onChanged(listener) → remove. * @property {object} trails create({ color, width }) → Trail, * createHead({ id, show, positions() → Cartesian3[], color, width }) → * TrailHead, removeHead(head). * @property {(objects: { sprites: Iterable, models: Iterable }) => object[]} surfaceExclusions - * What a vertical surface probe must not hit: the layer's own drawings. + * What a vertical surface probe must not hit: the layer's own mover sets. * @property {() => void} destroy */ diff --git a/src/layers/aircraft/render/navara.js b/src/layers/aircraft/render/display.js similarity index 71% rename from src/layers/aircraft/render/navara.js rename to src/layers/aircraft/render/display.js index 1bf46f3d0..1bf20de38 100644 --- a/src/layers/aircraft/render/navara.js +++ b/src/layers/aircraft/render/display.js @@ -1,9 +1,9 @@ -// The aircraft renderer on the Navara engine (contract: ./contract.js), -// built only on the engine contract: engine.movers (the fleet glyphs, the -// tracked glyph, the 3D models), engine.lines (the trail and its live head), +// The aircraft display (contract: ./contract.js): what the aircraft layers +// (flights, military, local ADS-B) draw, on any globe engine, through the +// engine contract only: engine.movers (the fleet glyphs, the tracked glyph, +// the 3D models), engine.lines (the trail and its live head), // engine.camera.follow (the follow camera), engine.projector and -// engine.camera.pose() (the view the logic reads). No three, no Cesium: the -// engine draws. Loaded only through the Navara mode's import(). +// engine.camera.pose() (the view the logic reads). The engine draws. // // Handles keep the Cesium field vocabulary the logic writes (contract.js) as // setters that write the engine's instance stores; the writes of a frame are @@ -11,10 +11,10 @@ // microtask for writes outside a frame), so a fleet tick draws in its frame. // // Sprites go in banks: one mover set per distance scale and depth mode the -// handles ask for (flights: one), growing by doubling. Models go in banks per -// glTF and look (url, minimum pixels, blend, unlit). The glyph atlas holds -// every aircraftIcon() kind at the fleet and tracked raster sizes, drawn -// once from the same SVGs Cesium's billboards load. +// handles ask for (flights: one), growing by doubling; the tracked glyph has +// its own, drawn over the fleet. Models go in banks per glTF and look (url, +// minimum pixels, blend, unlit). The glyph atlas holds every aircraftIcon() +// kind at the fleet and tracked raster sizes, drawn once from the same SVGs. import * as geo from '../../../geo/index.js'; import { @@ -29,21 +29,23 @@ import { createMoverBank, StoredColor, writeFields, -} from '../../shared/render/navaraMoverBank.js'; +} from '../../shared/render/moverBank.js'; const BASE_SIZE_PX = 20; /** - * The model frame → glTF scene axes on Navara: Cesium's own glTF axis - * correction, Y_UP_TO_Z_UP × Z_UP_TO_X_UP (data/modelVisualAnchor.js). - * Navara's glTF loader is three's GLTFLoader, which keeps the scene Y-up, - * so Z_UP_TO_X_UP alone would draw every model 90° off its heading. + * The model frame → the glTF's own axes (the movers' orientation): Cesium's + * glTF axis correction, Y_UP_TO_Z_UP × Z_UP_TO_X_UP (data/modelVisualAnchor.js), + * column-major. setPose keeps modelMatrix in the model frame, as the layers' + * anchors read it. */ -// Cesium's Axis.Y_UP_TO_Z_UP × Axis.Z_UP_TO_X_UP, column-major. -const NAVARA_GLTF_AXIS = [0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1]; -const ATLAS_PAD = 2; +const GLTF_AXIS = [0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1]; +// The trail's alpha where it passes the depth test and where the scene hides +// it (as data/trailRenderer.js draws track histories), and the live head's. const TRAIL_ALPHA = 0.85; const TRAIL_OCCLUDED_ALPHA = 0.4; +const HEAD_ALPHA = 0.9; +const HEAD_OCCLUDED_ALPHA = 0.45; const MIN_SEGMENT_DISTANCE_SQ = 0.01; // --------------------------------------------------------------------------- @@ -81,8 +83,7 @@ const scalarKey = (scalar) => // Follow handle → the tracked handle it follows, for every aircraft layer on // the engine: a layer whose follow another aircraft layer took reads the -// new owner's tracked handle (its gevSelectionOrigin), as Cesium's -// viewer.trackedEntity gives it the other layer's entity. +// new owner's tracked handle (its gevSelectionOrigin). const FOLLOWED_TRACKED = new WeakMap(); // The tracked handle whose follow is being started (any aircraft layer): // the engine raises followChanged inside follow(), before the follow @@ -90,12 +91,12 @@ const FOLLOWED_TRACKED = new WeakMap(); let followStarting = null; // --------------------------------------------------------------------------- -// The renderer +// The display // --------------------------------------------------------------------------- /** + * @param {import('../../../globe/contract.js').GlobeEngine} engine * @param {object} options - * @param {import('../../../globe/contract.js').GlobeEngine} options.engine * @param {string} options.layerId Names the layer's mover sets and lines. * @param {number} [options.order=0] Draw order of the layer's sprites. * @param {number} [options.headingOffsetDeg=0] Model nose vs heading 0. @@ -103,14 +104,19 @@ let followStarting = null; * Test seam: the glyph atlas. * @returns {import('./contract.js').AircraftRenderer} */ -export function createAircraftNavaraRenderer({ +export function createAircraftDisplay( engine, - layerId, - order = 0, - headingOffsetDeg = 0, - drawAtlas = drawAircraftIconAtlas, -}) { + { + layerId, + order = 0, + headingOffsetDeg = 0, + drawAtlas = drawAircraftIconAtlas, + }, +) { + if (!engine?.movers || !engine?.lines) + throw new TypeError('The aircraft display needs a globe engine'); let destroyed = false; + let attached = false; const removers = []; const iconCells = aircraftIconCells(); const defaultIcon = [...iconCells.keys()][0]; @@ -150,10 +156,11 @@ export function createAircraftNavaraRenderer({ removers.push(layerView.destroy); const { view, freshCamera } = layerView; - // --- fleet sprites --------------------------------------------------------- + // --- sprites: the fleet and the tracked glyph ------------------------------- let atlas = null; let fleetVisible = true; const spriteBanks = new Map(); + let spriteBankSeq = 0; const unbanked = new Set(); // sprites added before the atlas const spriteWrite = (sprite, set, index) => { writeFields(set, index, { @@ -166,20 +173,23 @@ export function createAircraftNavaraRenderer({ }); set.set(index, { pickId: sprite.id }); }; + // One bank per role (fleet, tracked), distance scale and depth mode. const spriteBankFor = (sprite) => { if (!atlas) return null; + const { tracked } = sprite; const depth = depthKey(sprite.disableDepthTestDistance); - const key = `${scalarKey(sprite.scaleByDistance)}|${JSON.stringify(depth)}`; + const key = `${tracked ? 'tracked' : 'fleet'}|${scalarKey(sprite.scaleByDistance)}|${JSON.stringify(depth)}`; let bank = spriteBanks.get(key); if (!bank) { const scalar = sprite.scaleByDistance; - const index = spriteBanks.size; + const index = spriteBankSeq++; bank = createMoverBank(engine, { options: (capacity, generation) => ({ - id: `${layerId}-sprites-${index}-${generation}`, + id: `${layerId}-${tracked ? 'tracked' : 'sprites'}-${index}-${generation}`, kind: 'sprite', capacity, - order, + // The tracked glyph over the fleet. + order: tracked ? order + 0.5 : order, depthTest: depth, pickable: true, sprite: { @@ -193,12 +203,13 @@ export function createAircraftNavaraRenderer({ write: spriteWrite, onCommit, }); - bank.setVisible(fleetVisible); + bank.setVisible(tracked || fleetVisible); spriteBanks.set(key, bank); } return bank; }; const placeSprite = (sprite) => { + if (!sprite.live) return; const bank = spriteBankFor(sprite); if (!bank) { unbanked.add(sprite); @@ -213,9 +224,7 @@ export function createAircraftNavaraRenderer({ atlas = drawn; const waiting = [...unbanked]; unbanked.clear(); - for (const sprite of waiting) if (sprite.live) placeSprite(sprite); - for (const sprite of trackedSprites) - if (sprite.live) placeTracked(sprite); + for (const sprite of waiting) placeSprite(sprite); engine.render.request(layerId); }) .catch((error) => @@ -223,11 +232,11 @@ export function createAircraftNavaraRenderer({ ); class Sprite { - constructor(spec, bankFor) { + constructor(spec, tracked = false) { this.gevBank = null; this.gevIndex = -1; this.live = true; - this.bankFor = bankFor; + this.tracked = tracked; this._position = geo.cartesian3.clone( spec.position ?? geo.cartesian3.ZERO, ); @@ -242,8 +251,8 @@ export function createAircraftNavaraRenderer({ this._scaleByDistance = spec.scaleByDistance ? { ...spec.scaleByDistance } : undefined; - this.disableDepthTestDistance = spec.disableDepthTestDistance ?? 0; - this.id = spec.id; + this._disableDepthTestDistance = spec.disableDepthTestDistance ?? 0; + this._id = spec.id; this.alignedAxis = spec.alignedAxis; this.sizeInMeters = false; } @@ -256,6 +265,18 @@ export function createAircraftNavaraRenderer({ this.gevBank?.update(this, fields); } + get id() { + return this._id; + } + + set id(value) { + this._id = value; + if (this.gevBank?.set) { + this.gevBank.set.set(this.gevIndex, { pickId: value }); + this.gevBank.touch(); + } + } + get position() { return this._position; } @@ -311,6 +332,7 @@ export function createAircraftNavaraRenderer({ this.write(spriteScaleField); } + // Glyphs are square: the width sizes them. get height() { return this._height; } @@ -358,25 +380,49 @@ export function createAircraftNavaraRenderer({ if (scalarKey(next) === scalarKey(this._scaleByDistance)) return; this._scaleByDistance = next; // Another distance scale is another set. - if (this.gevBank) this.bankFor(this); + if (this.gevBank) placeSprite(this); + } + + get disableDepthTestDistance() { + return this._disableDepthTestDistance; + } + + set disableDepthTestDistance(value) { + const next = value ?? 0; + if (next === this._disableDepthTestDistance) return; + this._disableDepthTestDistance = next; + // Another depth mode is another set. + if (this.gevBank) placeSprite(this); } } + const fleetBanks = () => + [...spriteBanks.entries()] + .filter(([key]) => key.startsWith('fleet|')) + .map(([, bank]) => bank); + const fleet = { - attach() {}, - detach() { - for (const bank of spriteBanks.values()) bank.destroy(); - spriteBanks.clear(); - unbanked.clear(); + attach() { + attached = !destroyed; }, - attached: () => !destroyed, - visible: () => !destroyed && fleetVisible, + detach() { + attached = false; + for (const [key, bank] of [...spriteBanks]) + if (key.startsWith('fleet|')) { + bank.destroy(); + spriteBanks.delete(key); + } + for (const sprite of unbanked) + if (!sprite.tracked) unbanked.delete(sprite); + }, + attached: () => attached && !destroyed, + visible: () => attached && !destroyed && fleetVisible, setVisible(visible) { fleetVisible = visible; - for (const bank of spriteBanks.values()) bank.setVisible(visible); + for (const bank of fleetBanks()) bank.setVisible(visible); }, add(spec) { - const sprite = new Sprite(spec, (s) => placeSprite(s)); + const sprite = new Sprite(spec); placeSprite(sprite); return sprite; }, @@ -386,6 +432,7 @@ export function createAircraftNavaraRenderer({ unbanked.delete(sprite); sprite.gevBank?.remove(sprite); }, + // Draw order is each set's `order` (the layer's data/spriteOrder slot). restoreOrder() {}, restoreOrderOnEnable() {}, }; @@ -447,18 +494,19 @@ export function createAircraftNavaraRenderer({ this._show = true; this._scale = spec.scale ?? 1; this._color = new StoredColor(spec.color); - this.id = spec.id; + this._id = spec.id; this.modelMatrix = geo.matrix4.create(); this.translation = geo.cartesian3.create(); this.orientation = geo.quaternion.create(0, 0, 0, 1); this.radiusM = spec.radiusM ?? 1; } + /** Placed, and the engine has loaded what this instance draws. */ get ready() { - return this.placed && Boolean(this.gevBank?.ready()); + return this.placed && Boolean(this.gevBank?.set?.ready(this.gevIndex)); } - /** The scale drawn: Cesium's minimumPixelSize kept on the CPU. */ + /** The scale drawn: minimumPixelSize kept on the CPU. */ get computedScale() { const minimum = this.spec.minimumPixelSize ?? 0; if (!(minimum > 0)) return this._scale; @@ -479,6 +527,18 @@ export function createAircraftNavaraRenderer({ return pixels < minimum ? this._scale * (minimum / pixels) : this._scale; } + get id() { + return this._id; + } + + set id(value) { + this._id = value; + if (this.gevBank?.set) { + this.gevBank.set.set(this.gevIndex, { pickId: value }); + this.gevBank.touch(); + } + } + get show() { return this._show; } @@ -508,14 +568,14 @@ export function createAircraftNavaraRenderer({ } const models = { - live: () => !destroyed, + live: () => attached && !destroyed, setVisible(visible) { modelsVisible = visible; 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 once per bank; nothing to warm. + // The engine loads each glTF itself; nothing to warm. preload: (url) => Promise.resolve({ url, destroy() {} }), add(model) { modelBankFor(model).add(model); @@ -526,9 +586,10 @@ export function createAircraftNavaraRenderer({ destroy(model) { model?.gevBank?.remove(model); }, - /** The same HPR frame as Cesium (modelMatrix); the instance turned by - * Navara's glTF axes (NAVARA_GLTF_AXIS). `offsetDeg`: this model's - * nose offset when the layer's models differ (default: the layer's). */ + /** 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 + * layer's). */ setPose(model, position, headingDeg, offsetDeg = headingOffsetDeg) { scratchHpr.heading = geo.math.toRadians((headingDeg || 0) + offsetDeg); geo.transforms.headingPitchRollToFixedFrame( @@ -540,7 +601,7 @@ export function createAircraftNavaraRenderer({ ); geo.matrix4.multiplyTransformation( model.modelMatrix, - NAVARA_GLTF_AXIS, + GLTF_AXIS, scratchCorrected, ); geo.quaternion.fromRotationMatrix( @@ -564,37 +625,19 @@ export function createAircraftNavaraRenderer({ // --- the tracked glyph ----------------------------------------------------- const trackedSprites = new Set(); - let trackedBank = null; - const trackedBankFor = () => { - if (!atlas) return null; - trackedBank ??= createMoverBank(engine, { - options: (capacity, generation) => ({ - id: `${layerId}-tracked-${generation}`, - kind: 'sprite', - capacity: Math.max(capacity, 4), - order: order + 0.5, - depthTest: 'always', - pickable: true, - sprite: { - atlas, - sizePx: BASE_SIZE_PX, - scaleByDistance: [1000, 3.0, 8000000, 0.5], - }, - }), - write: spriteWrite, - onCommit, - }); - return trackedBank; - }; - const placeTracked = (sprite) => trackedBankFor()?.add(sprite); let trackedSeq = 0; // Each frame (before drawing): the tracked glyphs read their callbacks. + // A followed glyph takes the position its follow centred this frame + // (Cesium's follow reads it before the frame's events). removers.push( engine.frames.preRender.addEventListener(() => inFrameDo(() => { for (const sprite of trackedSprites) { const { spec } = sprite; - const position = spec.position(); + const position = sprite.gevFollowSampled + ? sprite.gevFollowSample + : spec.position(); + sprite.gevFollowSampled = false; if (position) sprite.position = position; sprite.rotation = spec.rotation(); sprite.color = spec.color(); @@ -614,24 +657,26 @@ export function createAircraftNavaraRenderer({ scale: spec.scale, color: spec.color(), rotation: spec.rotation(), + scaleByDistance: spec.scaleByDistance, disableDepthTestDistance: spec.disableDepthTestDistance, id: `${layerId}-tracked-${++trackedSeq}`, }, - () => {}, + true, ); - // The tracked glyph's distance scale is its bank's (as Cesium's). - sprite._scaleByDistance = spec.scaleByDistance; sprite.spec = spec; sprite.resumeAircraftFollow = () => follow.start(sprite); sprite.viewFrom = null; + sprite.gevFollowSample = geo.cartesian3.create(); + sprite.gevFollowSampled = false; trackedSprites.add(sprite); - placeTracked(sprite); + placeSprite(sprite); return sprite; }, remove(handle) { if (!handle) return; handle.live = false; trackedSprites.delete(handle); + unbanked.delete(handle); handle.gevBank?.remove(handle); if ( handle.gevFollowHandle && @@ -658,22 +703,30 @@ export function createAircraftNavaraRenderer({ }; // --- follow --------------------------------------------------------------- - const ownFollows = FOLLOWED_TRACKED; // follow handle → tracked handle // The follow carries the tracked readout's card as plain data (the // handle's annotation fields, read when the card paints). - const followOf = (handle) => { - const card = { - id: handle.gevTrackedId, - model: () => handle.gevLabelModel, - position: () => handle.gevDisplayPosition?.() ?? handle.spec.position(), - }; - return engine.camera.follow({ + const followOf = (handle) => + engine.camera.follow({ native: handle, - position: () => handle.spec.position(), + position() { + const position = handle.spec.position(); + if (position) { + geo.cartesian3.clone(position, handle.gevFollowSample); + handle.gevFollowSampled = true; + } + return position; + }, viewFrom: () => handle.viewFrom, - label: card, + label: + typeof handle.gevTrackedId === 'string' + ? { + id: handle.gevTrackedId, + model: () => handle.gevLabelModel, + position: () => + handle.gevDisplayPosition?.() ?? handle.spec.position(), + } + : undefined, }); - }; const follow = { start(handle) { const outer = followStarting; @@ -685,9 +738,9 @@ export function createAircraftNavaraRenderer({ followStarting = outer; } handle.gevFollowHandle = followHandle; - ownFollows.set(followHandle, handle); - // Cesium returns the one-frame camera framing's disposer; the engine's - // follow frames itself. + 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) { @@ -699,17 +752,35 @@ export function createAircraftNavaraRenderer({ isFollowing: (handle) => Boolean(handle?.gevFollowHandle) && engine.camera.following() === handle.gevFollowHandle, + // What is followed: an aircraft layer's tracked handle, else what the + // follow is of (another layer's entity), else the follow itself. current() { if (followStarting) return followStarting; const handle = engine.camera.following(); - return handle ? (ownFollows.get(handle) ?? handle) : undefined; + return handle + ? (FOLLOWED_TRACKED.get(handle) ?? handle.native ?? handle) + : undefined; }, onChanged: (listener) => engine.camera.followChanged.addEventListener(listener), }; // --- trails ---------------------------------------------------------------- + // The layer's lines and heads, released with the display. + const lines = new Set(); + const heads = new Set(); let lineSeq = 0; + const lineOf = (options) => { + const line = engine.lines.createLine(options); + lines.add(line); + return line; + }; + const dropLine = (line) => { + if (line && lines.delete(line)) line.destroy(); + }; + const samePositions = (a, b) => + a.length === b.length && + a.every((position, index) => geo.cartesian3.equals(position, b[index])); const trails = { create({ color, width = 2.5 }) { const [r, g, b] = rgbaFromCss(color).rgba(); @@ -718,7 +789,7 @@ export function createAircraftNavaraRenderer({ let visible = true; let current = []; const ensure = () => - (line ??= engine.lines.createLine({ + (line ??= lineOf({ id, widthPx: width, color: [r, g, b, TRAIL_ALPHA], @@ -727,6 +798,8 @@ export function createAircraftNavaraRenderer({ pickId: id, })); return { + // Fewer than 2 distinct positions draw nothing; consecutive + // near-duplicates are dropped. setPositions(cartesians) { if (destroyed) return; const positions = []; @@ -754,28 +827,37 @@ export function createAircraftNavaraRenderer({ line?.setPositions(current); }, destroy() { - line?.destroy(); + dropLine(line); line = null; }, }; }, + // The live head segment: its positions are read each frame while shown. createHead({ id, show, positions, color, width = 2.5 }) { + if (destroyed) return null; const [r, g, b] = rgbaFromCss(color).rgba(); - const line = engine.lines.createLine({ + const line = lineOf({ id: `${id}`, widthPx: width, - color: [r, g, b, 0.9], - depthFailAlpha: 0.5, + color: [r, g, b, HEAD_ALPHA], + depthFailAlpha: HEAD_OCCLUDED_ALPHA / HEAD_ALPHA, arc: 'geodesic', pickId: id, }); let shown = show !== false; line.show(shown); + // Written only when they move: each write requests a render. + let drawn = []; const remove = engine.frames.preRender.addEventListener(() => { if (!shown) return; - line.setPositions(positions()); + const next = (positions() ?? []).map((position) => + geo.cartesian3.clone(position), + ); + if (samePositions(next, drawn)) return; + drawn = next; + line.setPositions(next); }); - return { + const head = { id, get show() { return shown; @@ -785,10 +867,13 @@ export function createAircraftNavaraRenderer({ line.show(shown); }, remove() { + if (!heads.delete(head)) return; remove(); - line.destroy(); + dropLine(line); }, }; + heads.add(head); + return head; }, removeHead(head) { head?.remove(); @@ -796,7 +881,6 @@ export function createAircraftNavaraRenderer({ }; return { - kind: 'navara', engine, view, fleet, @@ -806,41 +890,45 @@ export function createAircraftNavaraRenderer({ trails, /** The atlas is drawn (tests, QA). */ whenReady: () => atlasReady, - /** A vertical surface probe skips the layer's own mover sets (by id). */ + /** A vertical surface probe skips the layer's own mover sets. */ surfaceExclusions: () => - [...spriteBanks.values(), ...modelBanks.values(), trackedBank] - .map((bank) => bank?.set?.id) + [...spriteBanks.values(), ...modelBanks.values()] + .map((bank) => bank.set) .filter(Boolean), destroy() { if (destroyed) return; flush(); destroyed = true; + attached = false; for (const remove of removers.splice(0)) remove?.(); - fleet.detach(); + for (const bank of spriteBanks.values()) bank.destroy(); + spriteBanks.clear(); + unbanked.clear(); for (const bank of modelBanks.values()) bank.destroy(); modelBanks.clear(); - trackedBank?.destroy(); - trackedBank = null; trackedSprites.clear(); + for (const head of [...heads]) head.remove(); + for (const line of [...lines]) dropLine(line); }, }; } /** - * The Navara registry factory (src/app/layers/renderers.js) of one aircraft - * layer: its sprite slot (data/spriteOrder's order on Cesium) and its - * model's nose offset. + * The registry factory of one aircraft layer (src/app/layers/renderers.js + * and the Navara mode register the same one): its sprite slot + * (data/spriteOrder's order) and its model's nose offset. `drawAtlas` is a + * test seam. * @param {string} layerId * @param {number} headingOffsetDeg */ -export function aircraftLayerNavaraRenderer(layerId, headingOffsetDeg) { +export function aircraftLayerDisplay(layerId, headingOffsetDeg) { /** @param {import('../../../globe/contract.js').GlobeEngine} engine */ - return (engine) => - createAircraftNavaraRenderer({ - engine, + return (engine, { drawAtlas } = {}) => + createAircraftDisplay(engine, { layerId, order: SPRITE_LAYER_ORDER.indexOf(layerId), headingOffsetDeg, + ...(drawAtlas ? { drawAtlas } : {}), }); } diff --git a/src/layers/aircraft/render/display.test.mjs b/src/layers/aircraft/render/display.test.mjs new file mode 100644 index 000000000..df91671c6 --- /dev/null +++ b/src/layers/aircraft/render/display.test.mjs @@ -0,0 +1,854 @@ +// The aircraft display on the engine contract, on the fake engine and on the +// Cesium engine (real Cesium collections, billboards, models and +// polylines over a stub scene: they draw nothing but hold what the engine +// writes): the fleet's sprite sets, handle writes committed once per frame, +// the tracked glyph's per-frame callbacks, the follow and its readout label, +// model poses, loads and readiness, the view, the trail lines and teardown. + +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 { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs'; +import { aircraftIcon, TRACKED_ICON_PX } from '../../../data/aircraftIcons.js'; +import { CYAN, WHITE, nearFarScale, rgbaFromCss } from '../../shared/color.js'; +import { + aircraftAtlasLayout, + aircraftIconCells, + aircraftLayerDisplay, + createAircraftDisplay, +} from './display.js'; + +const layout = aircraftAtlasLayout(); +const drawAtlas = async () => ({ + canvas: { width: layout.width, height: layout.height }, + cells: layout.cells, +}); +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); +const AXIS = Cesium.Matrix4.multiplyTransformation( + Cesium.Axis.Y_UP_TO_Z_UP, + Cesium.Axis.Z_UP_TO_X_UP, + new Cesium.Matrix4(), +); +const SCALE_BY_DISTANCE = nearFarScale(1000, 3.0, 8000000, 0.5); + +function fakeHarness() { + const engine = createFakeEngine(); + return { + name: 'the fake engine', + engine, + frame: () => engine.fake.frame(), + destroy: () => engine.destroy(), + }; +} + +function cesiumHarness() { + const { engine, ...rest } = createCesiumMoversEngine(); + return { + name: 'the Cesium engine', + cesium: true, + engine, + ...rest, + destroy: () => engine.destroy(), + }; +} + +const HARNESSES = [fakeHarness, cesiumHarness]; + +async function setup(makeHarness, options = {}) { + const harness = makeHarness(); + harness.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(-97.7, 30.2, 50_000), + }); + const display = createAircraftDisplay(harness.engine, { + layerId: 'flights', + order: 8, + headingOffsetDeg: 180, + drawAtlas, + ...options, + }); + await display.whenReady(); + display.fleet.attach(); + return { ...harness, display }; +} + +const setsOf = (engine, prefix) => + engine.movers.list().filter(({ id }) => id.startsWith(prefix)); + +const near = (actual, expected, label, tolerance = 1e-6) => + assert.ok( + Math.abs(actual - expected) < tolerance, + `${label}: ${actual} vs ${expected}`, + ); + +const fleetSpec = (overrides = {}) => ({ + position: geo.cartesian3.fromDegrees(-97.7, 30.2, 3000), + image: aircraftIcon('helicopter'), + width: 20, + height: 20, + scale: 1.5, + rotation: 0.3, + color: rgbaFromCss('#FFB800'), + scaleByDistance: SCALE_BY_DISTANCE, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + id: 'abc123', + show: true, + ...overrides, +}); + +const trackedSpec = (state) => ({ + position: () => state.position, + image: aircraftIcon('airliner', TRACKED_ICON_PX), + width: 28, + height: 28, + scale: 1.2, + color: () => state.color, + scaleByDistance: SCALE_BY_DISTANCE, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + rotation: () => 0.25, +}); + +test('the display needs a globe engine', () => { + assert.throws(() => createAircraftDisplay(null, { layerId: 'x' }), TypeError); +}); + +test('the atlas has every glyph at the fleet and tracked sizes', () => { + const cells = aircraftIconCells(); + for (const key of cells.values()) + assert.ok(layout.cells[key], `${key} in the atlas`); + for (const [, [u0, v0, u1, v1]] of Object.entries(layout.cells)) + assert.ok(u0 >= 0 && v0 >= 0 && u1 <= 1 && v1 <= 1 && u1 > u0 && v1 > v0); + assert.equal( + cells.get(aircraftIcon('quadjet', TRACKED_ICON_PX)), + `quadjet@${TRACKED_ICON_PX}`, + ); +}); + +test("a layer's factory puts its sprites in its data/spriteOrder slot", async () => { + const engine = createFakeEngine(); + const display = aircraftLayerDisplay('military', 90)(engine, { drawAtlas }); + await display.whenReady(); + display.fleet.attach(); + display.fleet.add(fleetSpec()); + await Promise.resolve(); + const [listed] = setsOf(engine, 'military-sprites'); + assert.equal(engine.fake.moverSet(listed.id).options.order, 8); + display.destroy(); + engine.destroy(); +}); + +for (const makeHarness of HARNESSES) { + const { name } = makeHarness(); + + test(`fleet sprites draw as the layer asked, written once per frame (${name})`, async () => { + const h = await setup(makeHarness); + const { engine, display } = h; + assert.equal(display.fleet.attached(), true); + assert.equal(display.fleet.visible(), true); + const spec = fleetSpec(); + const sprite = display.fleet.add(spec); + assert.equal(sprite.id, 'abc123'); + assert.equal(sprite.width, 20); + await settle(); + const [listed] = setsOf(engine, 'flights-sprites'); + assert.equal(listed.count, 1); + if (h.cesium) { + const collection = h.primitive(listed.id); + assert.ok(collection instanceof Cesium.BillboardCollection); + const billboard = collection.get(0); + assert.ok(Cesium.Cartesian3.equals(billboard.position, spec.position)); + assert.equal(billboard.id, 'abc123'); + near(billboard.scale, 1.5, 'scale'); + near(billboard.rotation, 0.3, 'rotation'); + assert.equal(billboard.width, 20); + assert.equal(billboard.height, 20); + assert.equal( + billboard.disableDepthTestDistance, + Number.POSITIVE_INFINITY, + ); + assert.ok( + Cesium.NearFarScalar.equals( + billboard.scaleByDistance, + new Cesium.NearFarScalar(1000, 3, 8000000, 0.5), + ), + ); + assert.ok( + billboard.color.equalsEpsilon( + Cesium.Color.fromCssColorString('#FFB800'), + 1e-6, + ), + ); + assert.equal(billboard.show, true); + } else { + const set = engine.fake.moverSet(listed.id); + assert.equal(set.options.depthTest, 'always'); + assert.deepEqual( + set.options.sprite.scaleByDistance, + [1000, 3, 8000000, 0.5], + ); + assert.equal(set.options.sprite.sizePx, 20); + assert.equal(set.options.order, 8); + assert.equal(set.options.pickable, true); + const { store } = set; + assert.equal(store.positions[0], spec.position.x); + assert.equal(store.pickIds[0], 'abc123'); + assert.equal(store.cellKeys[store.cells[0]], 'helicopter@64'); + near(store.scales[0], 1.5, 'scale'); + near(store.rotations[0], 0.3, 'rotation'); + near(store.colors[1], 184 / 255, 'green'); + } + // Writes inside a frame listener commit at its end, once. + const commits = h.cesium + ? null + : () => engine.fake.moverSet(listed.id).commits; + const before = commits?.(); + const remove = display.view.on('preRender', () => { + sprite.color = WHITE.withAlpha(0.45); + sprite.scale = 2; + sprite.width = 24; + sprite.image = aircraftIcon('helicopter', TRACKED_ICON_PX); + sprite.show = false; + }); + h.frame(); + remove(); + if (commits) assert.equal(commits(), before + 1); + assert.equal(sprite.color.alpha, 0.45); + if (h.cesium) { + const billboard = h.primitive(listed.id).get(0); + near(billboard.color.alpha, 0.45, 'alpha'); + near(billboard.scale, 2.4, 'scale × width/20'); + assert.equal(billboard.show, false); + } else { + const { store } = engine.fake.moverSet(listed.id); + near(store.colors[3], 0.45, 'alpha'); + near(store.scales[0], 2.4, 'scale × width/20'); + assert.equal( + store.cellKeys[store.cells[0]], + `helicopter@${TRACKED_ICON_PX}`, + ); + assert.equal(store.shows[0], 0); + } + // Removal moves the last sprite into the hole. + const other = display.fleet.add( + fleetSpec({ image: aircraftIcon('light'), id: 'def456' }), + ); + display.fleet.remove(sprite); + await settle(); + assert.equal(setsOf(engine, 'flights-sprites')[0].count, 1); + assert.equal(other.gevIndex, 0); + if (h.cesium) assert.equal(h.primitive(listed.id).get(0).id, 'def456'); + else + assert.equal(engine.fake.moverSet(listed.id).store.pickIds[0], 'def456'); + // Another depth mode is another set. + other.disableDepthTestDistance = 0; + await settle(); + assert.equal(setsOf(engine, 'flights-sprites').length, 2); + display.fleet.setVisible(false); + await settle(); + assert.equal(display.fleet.visible(), false); + assert.ok(setsOf(engine, 'flights-sprites').every(({ count }) => !count)); + display.fleet.detach(); + assert.equal(display.fleet.attached(), false); + assert.equal(setsOf(engine, 'flights-sprites').length, 0); + display.destroy(); + assert.equal(engine.movers.list().length, 0, 'no set left behind'); + h.destroy(); + }); + + test(`a model pose is the heading-pitch-roll frame with the nose offset; the instance turns the glTF's own axes (${name})`, async () => { + const h = await setup(makeHarness); + const { engine, display } = h; + const model = await display.models.loadFleet({ + url: '/models/airplane.glb', + minimumPixelSize: 24, + scale: 1, + radiusM: 34.41, + color: WHITE, + blendAmount: 0.94, + unlit: false, + id: 'abc123', + }); + model.show = false; + display.models.add(model); + const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000); + const written = display.models.setPose(model, position, 75); + assert.equal(written, model.modelMatrix, 'the model own matrix'); + const expected = Cesium.Transforms.headingPitchRollToFixedFrame( + position, + new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(75 + 180), 0, 0), + Cesium.Ellipsoid.WGS84, + ); + for (let index = 0; index < 16; index++) + near(model.modelMatrix[index], expected[index], `modelMatrix ${index}`); + // What draws: the model frame × Cesium's glTF axis correction, which + // Cesium's own Model applied to the model frame (the movers load the + // glTF uncorrected). + const drawn = Cesium.Matrix4.multiplyTransformation( + expected, + AXIS, + new Cesium.Matrix4(), + ); + await settle(); + const [listed] = setsOf(engine, 'flights-models'); + assert.equal(listed.count, 1); + if (h.cesium) { + const [instance] = h.loads; + for (let index = 0; index < 12; index++) + near(instance.modelMatrix[index], drawn[index], `drawn ${index}`); + assert.equal(instance.show, false, 'admitted hidden'); + assert.equal(instance.id, 'abc123'); + assert.equal(model.ready, false, 'until the model has loaded'); + instance.ready = true; + assert.equal(model.ready, true); + } else { + const q = Cesium.Quaternion.fromRotationMatrix( + Cesium.Matrix4.getMatrix3(drawn, new Cesium.Matrix3()), + ); + const { store, options } = engine.fake.moverSet(listed.id); + assert.equal(options.model.minimumPixelSize, 24); + assert.deepEqual(options.model.colorBlend, { mode: 'mix', amount: 0.94 }); + for (const [k, value] of ['x', 'y', 'z', 'w'].entries()) + near(store.orientations[k], q[value], value, 1e-9); + assert.equal(store.shows[0], 0, 'admitted hidden'); + assert.equal(model.ready, true, 'the fake engine draws at once'); + } + model.show = true; + await settle(); + if (h.cesium) assert.equal(h.loads[0].show, true); + else + assert.equal( + engine.fake.moverSet(listed.id).store.shows[0], + 1, + 'shown once placed', + ); + assert.ok(model.computedScale >= model.scale); + // A per-model nose offset (military per-class specs). + display.models.setPose(model, position, 75, 90); + const offset = Cesium.Transforms.headingPitchRollToFixedFrame( + position, + new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(75 + 90), 0, 0), + Cesium.Ellipsoid.WGS84, + ); + for (let index = 0; index < 16; index++) + near(model.modelMatrix[index], offset[index], `offset ${index}`); + display.models.setVisible(false); + await settle(); + assert.equal(setsOf(engine, 'flights-models')[0].count, 0); + assert.equal(display.models.live(), true); + display.destroy(); + assert.equal(display.models.live(), false); + assert.equal(engine.movers.list().length, 0); + h.destroy(); + }); + + test(`the tracked glyph reads its callbacks every frame; the follow centres it and carries its readout label (${name})`, async () => { + const h = await setup(makeHarness); + const { engine, display } = h; + const state = { + position: geo.cartesian3.fromDegrees(-97.7, 30.2, 3000), + color: CYAN, + }; + const handle = display.tracked.create(trackedSpec(state)); + handle.gevTrackedId = 'flights:abc123'; + handle.gevLabelModel = { title: 'DAL123' }; + handle.gevDisplayPosition = () => state.position; + assert.equal(display.tracked.isLive(handle), true); + assert.deepEqual(display.tracked.billboardSize(handle), { + width: 28, + height: 28, + scale: 1.2, + scaleByDistance: { ...SCALE_BY_DISTANCE }, + }); + display.tracked.setViewFrom(handle, { x: 0, y: -2400, z: 1650 }); + assert.deepEqual( + [handle.viewFrom.x, handle.viewFrom.y, handle.viewFrom.z], + [0, -2400, 1650], + ); + h.frame(); + await settle(); + const [listed] = setsOf(engine, 'flights-tracked'); + assert.equal(listed.count, 1); + const read = () => { + if (h.cesium) { + const billboard = h.primitive(listed.id).get(0); + return { + x: billboard.position.x, + alpha: billboard.color.alpha, + rotation: billboard.rotation, + id: billboard.id, + }; + } + const { store } = engine.fake.moverSet(listed.id); + return { + x: store.positions[0], + alpha: store.colors[3], + rotation: store.rotations[0], + id: store.pickIds[0], + }; + }; + assert.equal(read().id, handle.id); + near(read().rotation, 0.25, 'rotation'); + if (h.cesium) { + const billboard = h.primitive(listed.id).get(0); + assert.equal( + billboard.disableDepthTestDistance, + Number.POSITIVE_INFINITY, + ); + assert.equal(billboard.width, 20); + near(billboard.scale, 1.2 * (28 / 20), 'scale × width/20'); + } + state.position = geo.cartesian3.fromDegrees(-97.6, 30.2, 3000); + state.color = CYAN.withAlpha(0); + h.frame(); + 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'); + const following = engine.camera.following(); + assert.equal(following, handle.gevFollowHandle); + assert.equal(following.native, handle, 'cockpit follows the glyph'); + assert.equal(following.label.id, 'flights:abc123'); + assert.equal(following.label.model(), handle.gevLabelModel); + assert.equal(display.follow.isFollowing(handle), true); + assert.equal(display.follow.current(), handle); + if (h.cesium) { + const proxy = h.viewer.trackedEntity; + assert.ok(proxy instanceof Cesium.Entity); + assert.equal( + proxy.trackingReferenceFrame, + Cesium.TrackingReferenceFrame.ENU, + ); + assert.equal(proxy.gevTrackedId, 'flights:abc123'); + const time = h.viewer.clock.currentTime; + assert.ok( + Cesium.Cartesian3.equals(proxy.viewFrom.getValue(time), { + x: 0, + y: -2400, + z: 1650, + }), + ); + } + // The glyph draws where the follow centred in its frame. + const centred = state.position; + assert.equal(following.target(), centred); + state.position = geo.cartesian3.fromDegrees(-97.5, 30.2, 3000); + if (h.cesium) { + h.frame(); + assert.equal(read().x, centred.x, "the follow's sample"); + h.frame(); + } else h.frame(); + assert.equal(read().x, state.position.x); + + display.follow.release(); + assert.equal(engine.camera.following(), null); + assert.equal(display.follow.current(), undefined); + assert.equal(display.follow.isFollowing(handle), false); + if (h.cesium) assert.equal(h.viewer.trackedEntity, undefined); + handle.resumeAircraftFollow(); + assert.equal(display.follow.current(), handle); + display.tracked.remove(handle); + assert.equal(engine.camera.following(), null); + assert.equal(display.tracked.isLive(handle), false); + await settle(); + assert.equal(setsOf(engine, 'flights-tracked')[0].count, 0); + display.destroy(); + h.destroy(); + }); + + test(`a tracked glyph hidden through its follow's target stays hidden, as the Cockpit rides it (${name})`, async () => { + const h = await setup(makeHarness); + const { engine, display } = h; + const state = { + position: geo.cartesian3.fromDegrees(-97.7, 30.2, 3000), + color: CYAN, + }; + const handle = display.tracked.create(trackedSpec(state)); + display.tracked.setViewFrom(handle, { x: 0, y: -2400, z: 1650 }); + display.follow.start(handle); + h.frame(); + await settle(); + const [listed] = setsOf(engine, 'flights-tracked'); + const drawn = () => + h.cesium + ? h.primitive(listed.id).get(0).show + : engine.fake.moverSet(listed.id).store.shows[0] === 1; + assert.equal(drawn(), true); + // The Cockpit hides what the engine follows, then releases the follow. + const ridden = engine.camera.following().native; + assert.equal(ridden, handle); + ridden.show = false; + engine.camera.following().stop(); + state.position = geo.cartesian3.fromDegrees(-97.6, 30.2, 3000); + h.frame(); + await settle(); + assert.equal(drawn(), false, 'not drawn while ridden'); + // Another distance scale moves it to another set, still hidden. + handle.scaleByDistance = nearFarScale(1000, 2.0, 8000000, 0.5); + await settle(); + const moved = setsOf(engine, 'flights-tracked').find( + ({ count }) => count === 1, + ); + const shownIn = (set) => + h.cesium + ? h.primitive(set.id).get(0).show + : engine.fake.moverSet(set.id).store.shows[0] === 1; + assert.equal(shownIn(moved), false); + ridden.show = true; + ridden.resumeAircraftFollow(); + h.frame(); + await settle(); + assert.equal(shownIn(moved), true, 'drawn again on exit'); + assert.equal(display.follow.current(), handle); + display.destroy(); + h.destroy(); + }); + + test(`another layer's follow reads as what it follows (${name})`, async () => { + const h = await setup(makeHarness); + const { engine, display } = h; + const changes = []; + const remove = display.follow.onChanged(() => + changes.push(display.follow.current()), + ); + const native = h.cesium + ? new Cesium.Entity({ + position: geo.cartesian3.fromDegrees(0, 0, 400_000), + viewFrom: new Cesium.Cartesian3(0, -900, 400), + }) + : { gevSelectionOrigin: 'user' }; + if (h.cesium) { + native.gevSelectionOrigin = 'user'; + h.viewer.entities.add(native); + } + engine.camera.follow({ + native, + position: () => geo.cartesian3.fromDegrees(0, 0, 400_000), + viewFrom: { x: 0, y: -900, z: 400 }, + }); + assert.equal(display.follow.current(), native); + assert.equal(display.follow.current().gevSelectionOrigin, 'user'); + assert.deepEqual(changes, [native]); + display.follow.release(); + assert.equal(engine.camera.following(), null); + remove(); + display.destroy(); + h.destroy(); + }); + + test(`trails are geodesic lines in the trail pick namespace; the head follows each frame (${name})`, async () => { + const h = await setup(makeHarness); + const { engine, display } = h; + const trail = display.trails.create({ color: '#00d4ff', width: 2.5 }); + const a = geo.cartesian3.fromDegrees(0, 0, 1000); + const b = geo.cartesian3.fromDegrees(0.1, 0, 1000); + trail.setPositions([a, a, b]); + const [body] = engine.lines.list(); + assert.match(body.id, /^gev-trail:flights-/); + assert.equal(body.points, 2, 'near-duplicates dropped'); + const base = Cesium.Color.fromCssColorString('#00d4ff'); + const alphas = (id) => { + if (h.cesium) { + const primitive = h.primitive(id); + assert.equal(primitive.allowPicking, true); + assert.equal( + primitive.geometryInstances.geometry._arcType, + Cesium.ArcType.GEODESIC, + ); + assert.equal(primitive.geometryInstances.id, id); + return [ + primitive.appearance.material.uniforms.color.alpha, + primitive.depthFailAppearance.material.uniforms.color.alpha, + ]; + } + const { options } = engine.fake.line(id); + assert.equal(options.arc, 'geodesic'); + assert.equal(options.pickId, id); + return [options.color[3], options.color[3] * options.depthFailAlpha]; + }; + // 0.85 where it passes the depth test, 0.4 where the scene hides it. + const [bodyAlpha, bodyHidden] = alphas(body.id); + near(bodyAlpha, 0.85, 'trail alpha'); + near(bodyHidden, 0.4, 'trail occluded alpha'); + if (h.cesium) { + const { color } = h.primitive(body.id).appearance.material.uniforms; + near(color.red, base.red, 'trail red'); + } + trail.setPositions([a]); + assert.equal(engine.lines.list()[0].points, 0, 'one point draws nothing'); + trail.setVisible(false); + assert.equal(engine.lines.list()[0].shown, false); + trail.setVisible(true); + let ends = [a, b]; + const head = display.trails.createHead({ + id: 'gev-trail:fl-head-1', + show: true, + positions: () => ends, + color: '#00d4ff', + width: 2.5, + }); + assert.equal(head.id, 'gev-trail:fl-head-1'); + h.frame(); + const headLine = engine.lines + .list() + .find(({ id }) => id === 'gev-trail:fl-head-1'); + assert.equal(headLine.points, 2); + // 0.9 where it passes the depth test, 0.45 where the scene hides it. + const [headAlpha, headHidden] = alphas(headLine.id); + near(headAlpha, 0.9, 'head alpha'); + near(headHidden, 0.45, 'head occluded alpha'); + head.show = false; + assert.equal(head.show, false); + ends = []; + h.frame(); + assert.equal( + engine.lines.list().find(({ id }) => id === headLine.id).points, + 2, + 'a hidden head is not read', + ); + display.trails.removeHead(head); + trail.destroy(); + assert.equal(engine.lines.list().length, 0); + // The display releases what the layer left. + display.trails.createHead({ + id: 'gev-trail:fl-head-2', + positions: () => [a, b], + color: '#00d4ff', + }); + display.trails.create({ color: '#00d4ff' }).setPositions([a, b]); + assert.equal(engine.lines.list().length, 2); + display.destroy(); + assert.equal(engine.lines.list().length, 0); + h.frame(); + h.destroy(); + }); + + test(`the view: camera, frustum, horizon, frame number and viewport (${name})`, async () => { + const h = await setup(makeHarness); + const { engine, display } = h; + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(-73.78, 40.0, 50_000), + }); + const { view } = display; + near(view.camera.positionCartographic.height, 50_000, 'height', 1); + const inFrustum = view.frustumTester(); + const below = geo.cartesian3.fromDegrees(-73.78, 40.0, 0); + const behind = geo.cartesian3.fromDegrees(106.22, -40.0, 0); + const aboveCamera = geo.cartesian3.fromDegrees(-73.78, 40.0, 200_000); + assert.equal(inFrustum(below), true); + assert.equal(inFrustum(aboveCamera), false); + assert.equal(view.horizon().isPointVisible(below), true); + assert.equal(view.horizon().isPointVisible(behind), false); + const screen = view.worldToWindow(below); + const { width, height } = engine.host.size(); + near(screen.x, width / 2, 'centre x', 1); + near(screen.y, height / 2, 'centre y', 1); + assert.equal(view.viewportHeightPx(), height); + const frame = view.frameNumber(); + h.frame(); + assert.equal(view.frameNumber(), frame + 1); + let moved = 0; + const remove = view.on('preUpdate', () => moved++); + h.frame(); + remove(); + h.frame(); + assert.equal(moved, 1); + display.destroy(); + h.destroy(); + }); + + test(`the surface probe skips the layer's own mover sets (${name})`, async () => { + const h = await setup(makeHarness); + const { engine, display } = h; + display.fleet.add(fleetSpec()); + const model = await display.models.loadFleet({ + url: '/models/airplane.glb', + id: 'abc123', + }); + display.models.add(model); + await settle(); + const excluded = display.surfaceExclusions({ + sprites: [], + models: [model], + }); + assert.deepEqual( + excluded.map((set) => set.id).sort(), + engine.movers + .list() + .map(({ id }) => id) + .sort(), + ); + // The engine's surface takes them (Cesium: hidden for the sample). + assert.doesNotThrow(() => + engine.surface.sampleHeight( + geo.cartographic.fromDegrees(-97.7, 30.2, 0), + excluded, + ), + ); + display.destroy(); + h.destroy(); + }); +} + +test('the bank grows past its capacity without losing sprites', async () => { + const { engine, display, destroy } = await setup(fakeHarness); + for (let index = 0; index < 1500; index++) + display.fleet.add({ + position: geo.cartesian3.fromDegrees(index * 0.01, 0, 1000), + image: aircraftIcon('airliner'), + id: `a${index}`, + scaleByDistance: SCALE_BY_DISTANCE, + }); + await Promise.resolve(); + const sets = setsOf(engine, 'flights-sprites'); + assert.equal(sets.length, 1); + assert.equal(sets[0].count, 1500); + assert.ok(sets[0].capacity >= 1500); + const { store } = engine.fake.moverSet(sets[0].id); + assert.equal(store.pickIds[1499], 'a1499'); + display.destroy(); + destroy(); +}); + +test('sprites added before the atlas draw once it is', async () => { + const engine = createFakeEngine(); + let finish; + const display = createAircraftDisplay(engine, { + layerId: 'flights', + drawAtlas: () => new Promise((resolve) => (finish = resolve)), + }); + display.fleet.attach(); + const sprite = display.fleet.add(fleetSpec()); + await Promise.resolve(); + assert.equal(setsOf(engine, 'flights-sprites').length, 0); + sprite.scale = 2; + finish(await drawAtlas()); + await display.whenReady(); + await Promise.resolve(); + const [listed] = setsOf(engine, 'flights-sprites'); + assert.equal(listed.count, 1); + near(engine.fake.moverSet(listed.id).store.scales[0], 2, 'latest scale'); + display.destroy(); + engine.destroy(); +}); + +test('unchanged sprite style does not upload or request another frame', async () => { + const { engine, display, destroy } = await setup(fakeHarness); + const sprite = display.fleet.add({ + id: 'same', + position: geo.cartesian3.fromDegrees(0, 0, 1000), + image: aircraftIcon('light'), + }); + await Promise.resolve(); + const [listed] = setsOf(engine, 'flights-sprites'); + const part = engine.fake.moverSet(listed.id); + const before = part.commits; + const color = sprite.color; + for (let n = 0; n < 1000; n++) { + sprite.show = true; + sprite.rotation = 0; + sprite.scale = 1; + sprite.width = 20; + sprite.color = WHITE; + } + await Promise.resolve(); + assert.equal(part.commits, before); + assert.equal(sprite.color, color); + display.destroy(); + destroy(); +}); + +test('reused sprite field records are copied, including a mutated color getter', async () => { + const { engine, display, destroy } = await setup(fakeHarness); + const a = display.fleet.add({ + id: 'a', + position: geo.cartesian3.fromDegrees(0, 0, 1000), + image: aircraftIcon('light'), + }); + const b = display.fleet.add({ + id: 'b', + position: a.position, + image: aircraftIcon('light'), + }); + await Promise.resolve(); + const [listed] = setsOf(engine, 'flights-sprites'); + const { store } = engine.fake.moverSet(listed.id); + const position = geo.cartesian3.create(7, 8, 9); + a.position = position; + position.x = 20; + b.position = position; + a.color.alpha = 0.25; + // eslint-disable-next-line no-self-assign + a.color = a.color; + b.color = WHITE.withAlpha(0.75); + await Promise.resolve(); + assert.deepEqual([...store.positions.subarray(0, 6)], [7, 8, 9, 20, 8, 9]); + assert.equal(store.colors[3], 0.25); + assert.equal(store.colors[7], 0.75); + display.destroy(); + destroy(); +}); + +test("model loads on Cesium carry the layer's look; the IR boost is unlit; readiness is per model", async () => { + const h = await setup(cesiumHarness); + const { display, loads } = h; + const spec = { + url: '/models/airplane.glb', + minimumPixelSize: 24, + scale: 1, + color: CYAN, + blendAmount: 0.94, + unlit: false, + id: 'abc123', + }; + const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000); + const fleet = await display.models.loadFleet(spec); + const second = await display.models.loadFleet({ ...spec, id: 'def456' }); + const boosted = await display.models.loadTracked({ + ...spec, + unlit: true, + minimumPixelSize: 40, + id: 'ghi789', + }); + for (const model of [fleet, second, boosted]) { + display.models.add(model); + display.models.setPose(model, position, 10); + } + await settle(); + assert.equal(loads.length, 3, 'one Cesium model per instance'); + const [first, other, unlit] = loads; + for (const { options } of loads) { + assert.equal(options.asynchronous, false); + assert.deepEqual(options.environmentMapOptions, { enabled: false }); + assert.equal(options.url, '/models/airplane.glb'); + assert.equal(options.colorBlendMode, Cesium.ColorBlendMode.MIX); + assert.equal(options.colorBlendAmount, 0.94); + assert.equal(options.allowPicking, true); + } + assert.equal(first.options.minimumPixelSize, 24); + assert.equal(first.options.customShader, undefined); + assert.equal(unlit.options.minimumPixelSize, 40); + assert.equal( + unlit.options.customShader.lightingModel, + Cesium.LightingModel.UNLIT, + ); + assert.equal(first.id, 'abc123', 'picks name the aircraft'); + assert.equal(unlit.id, 'ghi789'); + assert.ok(first.color.equalsEpsilon(Cesium.Color.CYAN, 1e-6)); + // Readiness is each model's own: one loading model leaves the others up. + first.ready = true; + unlit.ready = true; + assert.equal(fleet.ready, true); + assert.equal(second.ready, false); + assert.equal(boosted.ready, true); + other.ready = true; + assert.equal(second.ready, true); + // A removed model's place goes to the last one, ready with its model. + display.models.remove(fleet); + await settle(); + assert.equal(second.gevIndex, 0); + assert.equal(second.ready, true); + display.destroy(); + h.destroy(); +}); diff --git a/src/layers/aircraft/render/navara.test.mjs b/src/layers/aircraft/render/navara.test.mjs deleted file mode 100644 index 0ef9e33b7..000000000 --- a/src/layers/aircraft/render/navara.test.mjs +++ /dev/null @@ -1,357 +0,0 @@ -// The aircraft renderer on the engine contract (Navara's), over the fake -// engine: the contract's members, sprite banks with the glyph atlas, handle -// writes committed once per frame, the tracked glyph's per-frame callbacks, -// the follow and its readout label, model poses and the trail lines. - -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 { aircraftIcon, TRACKED_ICON_PX } from '../../../data/aircraftIcons.js'; -import { CYAN, WHITE, nearFarScale, rgbaFromCss } from '../../shared/color.js'; -import { - aircraftAtlasLayout, - aircraftIconCells, - createAircraftNavaraRenderer, -} from './navara.js'; - -const layout = aircraftAtlasLayout(); -const drawAtlas = async () => ({ - canvas: { width: layout.width, height: layout.height }, - cells: layout.cells, -}); - -async function setup() { - const engine = createFakeEngine(); - const renderer = createAircraftNavaraRenderer({ - engine, - layerId: 'flights', - order: 8, - headingOffsetDeg: 180, - drawAtlas, - }); - await renderer.whenReady(); - return { engine, renderer }; -} - -const setsOf = (engine, prefix) => - engine.movers.list().filter(({ id }) => id.startsWith(prefix)); - -test('the Navara aircraft renderer reports its kind', async () => { - const { engine, renderer } = await setup(); - assert.equal(renderer.kind, 'navara'); - renderer.destroy(); - engine.destroy(); -}); - -test('the atlas has every glyph at the fleet and tracked sizes', () => { - const cells = aircraftIconCells(); - for (const key of cells.values()) - assert.ok(layout.cells[key], `${key} in the atlas`); - for (const [, [u0, v0, u1, v1]] of Object.entries(layout.cells)) - assert.ok(u0 >= 0 && v0 >= 0 && u1 <= 1 && v1 <= 1 && u1 > u0 && v1 > v0); - assert.equal( - cells.get(aircraftIcon('quadjet', TRACKED_ICON_PX)), - `quadjet@${TRACKED_ICON_PX}`, - ); -}); - -test('fleet sprites write the mover store, once per frame', async () => { - const { engine, renderer } = await setup(); - const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000); - const sprite = renderer.fleet.add({ - position, - image: aircraftIcon('helicopter'), - width: 20, - height: 20, - scale: 1.5, - rotation: 0.3, - color: rgbaFromCss('#FFB800'), - scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - id: 'abc123', - show: true, - }); - await Promise.resolve(); - const [listed] = setsOf(engine, 'flights-sprites'); - const set = engine.fake.moverSet(listed.id); - assert.equal(set.options.depthTest, 'always'); - assert.deepEqual(set.options.sprite.scaleByDistance, [1000, 3, 8000000, 0.5]); - assert.equal(set.options.sprite.sizePx, 20); - assert.equal(set.options.order, 8); - const { store } = set; - assert.equal(store.count, 1); - assert.equal(store.positions[0], position.x); - assert.equal(store.pickIds[0], 'abc123'); - assert.equal(store.cellKeys[store.cells[0]], 'helicopter@64'); - assert.ok(Math.abs(store.scales[0] - 1.5) < 1e-6); - assert.ok(Math.abs(store.rotations[0] - 0.3) < 1e-6); - assert.ok(Math.abs(store.colors[1] - 184 / 255) < 1e-6); - // Writes inside a frame listener commit at its end, once. - const before = set.commits; - const remove = renderer.view.on('preRender', () => { - sprite.color = WHITE.withAlpha(0.45); - sprite.scale = 2; - sprite.width = 24; - sprite.image = aircraftIcon('helicopter', TRACKED_ICON_PX); - sprite.show = false; - }); - engine.fake.frame(); - remove(); - assert.equal(engine.fake.moverSet(listed.id).commits, before + 1); - assert.ok(Math.abs(store.colors[3] - 0.45) < 1e-6); - assert.ok(Math.abs(store.scales[0] - 2.4) < 1e-6, 'scale × width/20'); - assert.equal(store.cellKeys[store.cells[0]], `helicopter@${TRACKED_ICON_PX}`); - assert.equal(store.shows[0], 0); - assert.equal(sprite.color.alpha, 0.45); - // Removal moves the last sprite into the hole. - const other = renderer.fleet.add({ - position, - image: aircraftIcon('light'), - id: 'def456', - scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }); - renderer.fleet.remove(sprite); - await Promise.resolve(); - assert.equal(store.count, 1); - assert.equal(store.pickIds[0], 'def456'); - assert.equal(other.gevIndex, 0); - renderer.fleet.setVisible(false); - await Promise.resolve(); - assert.equal(store.count, 0); - assert.equal(renderer.fleet.visible(), false); - renderer.destroy(); - assert.equal(setsOf(engine, 'flights').length, 0, 'no set left behind'); - engine.destroy(); -}); - -test('the bank grows past its capacity without losing sprites', async () => { - const { engine, renderer } = await setup(); - const sprites = []; - for (let index = 0; index < 1500; index++) - sprites.push( - renderer.fleet.add({ - position: geo.cartesian3.fromDegrees(index * 0.01, 0, 1000), - image: aircraftIcon('airliner'), - id: `a${index}`, - scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), - }), - ); - await Promise.resolve(); - const sets = setsOf(engine, 'flights-sprites'); - assert.equal(sets.length, 1); - assert.equal(sets[0].count, 1500); - assert.ok(sets[0].capacity >= 1500); - const { store } = engine.fake.moverSet(sets[0].id); - assert.equal(store.pickIds[1499], 'a1499'); - renderer.destroy(); - engine.destroy(); -}); - -test('the tracked glyph reads its callbacks every frame; follow carries its readout label', async () => { - const { engine, renderer } = await setup(); - let position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000); - let color = CYAN; - const handle = renderer.tracked.create({ - position: () => position, - image: aircraftIcon('airliner', TRACKED_ICON_PX), - width: 28, - height: 28, - scale: 1, - color: () => color, - scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - rotation: () => 0.5, - }); - handle.gevTrackedId = 'flights:abc123'; - handle.gevLabelModel = { title: 'DAL123' }; - handle.gevDisplayPosition = () => position; - renderer.tracked.setViewFrom(handle, { x: 0, y: -2400, z: 1650 }); - engine.fake.frame(); - const [listed] = setsOf(engine, 'flights-tracked'); - const { store } = engine.fake.moverSet(listed.id); - assert.equal(store.pickIds[0], handle.id); - assert.ok(Math.abs(store.rotations[0] - 0.5) < 1e-6); - position = geo.cartesian3.fromDegrees(-97.6, 30.2, 3000); - color = CYAN.withAlpha(0); - engine.fake.frame(); - assert.equal( - store.positions[0], - position.x, - 'the position callback, per frame', - ); - assert.equal(store.colors[3], 0, 'the colour callback, per frame'); - - const stop = renderer.follow.start(handle); - assert.equal(typeof stop, 'function'); - const following = engine.camera.following(); - assert.equal(following, handle.gevFollowHandle); - assert.equal( - following.native, - handle, - 'cockpit follows the rendered aircraft', - ); - assert.equal(following.label.id, 'flights:abc123'); - assert.equal(following.label.model(), handle.gevLabelModel); - assert.equal(renderer.follow.isFollowing(handle), true); - assert.equal(renderer.follow.current(), handle); - renderer.follow.release(); - assert.equal(engine.camera.following(), null); - assert.equal(renderer.follow.current(), undefined); - handle.resumeAircraftFollow(); - assert.equal(renderer.follow.current(), handle); - renderer.tracked.remove(handle); - assert.equal(engine.camera.following(), null); - assert.equal(renderer.tracked.isLive(handle), false); - renderer.destroy(); - engine.destroy(); -}); - -test("a model pose is the Cesium model frame, turned to Navara's glTF axes", async () => { - const { engine, renderer } = await setup(); - const model = await renderer.models.loadFleet({ - url: '/models/airplane.glb', - minimumPixelSize: 24, - scale: 1, - radiusM: 34.41, - color: WHITE, - blendAmount: 0.94, - unlit: false, - id: 'abc123', - }); - model.show = false; - renderer.models.add(model); - const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000); - renderer.models.setPose(model, position, 75); - // modelMatrix is Cesium's (what the trail anchors read). - const expected = Cesium.Transforms.headingPitchRollToFixedFrame( - position, - new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(75 + 180), 0, 0), - Cesium.Ellipsoid.WGS84, - ); - for (let index = 0; index < 16; index++) - assert.ok(Math.abs(model.modelMatrix[index] - expected[index]) < 1e-6); - // The instance orientation: modelMatrix × Cesium's glTF axis correction - // (Y_UP_TO_Z_UP × Z_UP_TO_X_UP; Navara's loader keeps the glTF Y-up). - const corrected = Cesium.Matrix4.multiplyTransformation( - expected, - Cesium.Matrix4.multiplyTransformation( - Cesium.Axis.Y_UP_TO_Z_UP, - Cesium.Axis.Z_UP_TO_X_UP, - new Cesium.Matrix4(), - ), - new Cesium.Matrix4(), - ); - const q = Cesium.Quaternion.fromRotationMatrix( - Cesium.Matrix4.getMatrix3(corrected, new Cesium.Matrix3()), - ); - await Promise.resolve(); - const [listed] = setsOf(engine, 'flights-models'); - const set = engine.fake.moverSet(listed.id); - assert.equal(set.options.model.minimumPixelSize, 24); - assert.deepEqual(set.options.model.colorBlend, { mode: 'mix', amount: 0.94 }); - const { store } = set; - for (const [k, value] of ['x', 'y', 'z', 'w'].entries()) - assert.ok( - Math.abs(store.orientations[k] - q[value]) < 1e-9, - `${value}: ${[...store.orientations.slice(0, 4)]} vs ${[q.x, q.y, q.z, q.w]}`, - ); - assert.equal(store.shows[0], 0, 'admitted hidden'); - model.show = true; - await Promise.resolve(); - assert.equal(store.shows[0], 1); - assert.equal(model.ready, true, 'the fake engine draws at once'); - assert.ok(model.computedScale >= model.scale); - renderer.destroy(); - engine.destroy(); -}); - -test('trails are engine lines in the trail pick namespace; the head follows each frame', async () => { - const { engine, renderer } = await setup(); - const trail = renderer.trails.create({ color: '#00d4ff', width: 2.5 }); - const a = geo.cartesian3.fromDegrees(0, 0, 1000); - const b = geo.cartesian3.fromDegrees(0.1, 0, 1000); - trail.setPositions([a, a, b]); - const lines = engine.lines.list(); - assert.equal(lines.length, 1); - const line = engine.fake.line(lines[0].id); - assert.match(lines[0].id, /^gev-trail:/); - assert.equal(line.options.pickId, lines[0].id); - assert.ok(Math.abs(line.options.color[3] - 0.85) < 1e-9); - assert.equal(line.options.arc, 'geodesic'); - let ends = [a, b]; - const head = renderer.trails.createHead({ - id: 'gev-trail:fl-head-1', - show: true, - positions: () => ends, - color: '#00d4ff', - width: 2.5, - }); - engine.fake.frame(); - assert.equal(engine.lines.list().length, 2); - head.show = false; - ends = []; - renderer.trails.removeHead(head); - trail.destroy(); - assert.equal(engine.lines.list().length, 0); - renderer.destroy(); - engine.destroy(); -}); - -test('unchanged sprite style does not upload or request another frame', async () => { - const { engine, renderer } = await setup(); - const sprite = renderer.fleet.add({ - id: 'same', - position: geo.cartesian3.fromDegrees(0, 0, 1000), - image: aircraftIcon('light'), - }); - await Promise.resolve(); - const [listed] = setsOf(engine, 'flights-sprites'); - const part = engine.fake.moverSet(listed.id); - const before = part.commits; - const color = sprite.color; - for (let n = 0; n < 1000; n++) { - sprite.show = true; - sprite.rotation = 0; - sprite.scale = 1; - sprite.width = 20; - sprite.color = WHITE; - } - await Promise.resolve(); - assert.equal(part.commits, before); - assert.equal(sprite.color, color); - renderer.destroy(); - engine.destroy(); -}); - -test('reused sprite field records are copied, including a mutated color getter', async () => { - const { engine, renderer } = await setup(); - const a = renderer.fleet.add({ - id: 'a', - position: geo.cartesian3.fromDegrees(0, 0, 1000), - image: aircraftIcon('light'), - }); - const b = renderer.fleet.add({ - id: 'b', - position: a.position, - image: aircraftIcon('light'), - }); - await Promise.resolve(); - const [listed] = setsOf(engine, 'flights-sprites'); - const { store } = engine.fake.moverSet(listed.id); - const position = geo.cartesian3.create(7, 8, 9); - a.position = position; - position.x = 20; - b.position = position; - a.color.alpha = 0.25; - a.color = a.color; - b.color = WHITE.withAlpha(0.75); - await Promise.resolve(); - assert.deepEqual([...store.positions.subarray(0, 6)], [7, 8, 9, 20, 8, 9]); - assert.equal(store.colors[3], 0.25); - assert.equal(store.colors[7], 0.75); - renderer.destroy(); - engine.destroy(); -}); diff --git a/src/layers/bikeshare/render/cesium/lifecycle.js b/src/layers/bikeshare/render/cesium/lifecycle.js index 723d1db1d..0dd2b1d9b 100644 --- a/src/layers/bikeshare/render/cesium/lifecycle.js +++ b/src/layers/bikeshare/render/cesium/lifecycle.js @@ -11,6 +11,7 @@ export function createLifecycle({ services, parts, source, + engine, }) { const { registerSpriteCollection, @@ -78,7 +79,7 @@ export function createLifecycle({ // Through the shared ledger: this is a single number every layer's // camera.changed listener shares, and a layer leaving must not hand // back a coarse default while this one is still driving off it. - claimCameraSensitivity(viewer.camera, 'bikeshare', 0.05); + claimCameraSensitivity(engine ?? viewer.camera, 'bikeshare', 0.05); layerState._cameraChangedAttached = true; } @@ -116,7 +117,7 @@ export function createLifecycle({ viewer.camera.changed.removeEventListener( parts.viewport.onCameraChanged, ); - releaseCameraSensitivity(viewer.camera, 'bikeshare'); + releaseCameraSensitivity(engine ?? viewer.camera, 'bikeshare'); layerState._cameraChangedAttached = false; } @@ -140,7 +141,7 @@ export function createLifecycle({ viewer.camera.changed.removeEventListener( parts.viewport.onCameraChanged, ); - releaseCameraSensitivity(viewer.camera, 'bikeshare'); + releaseCameraSensitivity(engine ?? viewer.camera, 'bikeshare'); layerState._cameraChangedAttached = false; } unregisterSpriteCollection?.('bikeshare', layerState._pointCollection); @@ -172,7 +173,7 @@ export function createLifecycle({ viewer.camera.changed.removeEventListener( parts.viewport.onCameraChanged, ); - releaseCameraSensitivity(viewer.camera, 'bikeshare'); + releaseCameraSensitivity(engine ?? viewer.camera, 'bikeshare'); layerState._cameraChangedAttached = false; } parts.ingestion.abortAllInFlight(); diff --git a/src/layers/flights/ownership.test.mjs b/src/layers/flights/ownership.test.mjs index 6a6a28189..4ba2e91c2 100644 --- a/src/layers/flights/ownership.test.mjs +++ b/src/layers/flights/ownership.test.mjs @@ -150,8 +150,8 @@ test('voice context reads a track that rounds to north as 0°, not 360°', () => test('a live flight keeps observations, selection and freshness across renderer lifetimes', async () => { const { createFakeEngine } = await import('../../globe/testing/fakeEngine.js'); - const { aircraftAtlasLayout, createAircraftNavaraRenderer } = - await import('../aircraft/render/navara.js'); + const { aircraftAtlasLayout, createAircraftDisplay } = + await import('../aircraft/render/display.js'); const { createApplicationFlights } = await import('../../app/layers/flights.js'); const first = createFakeEngine(); @@ -203,8 +203,7 @@ test('a live flight keeps observations, selection and freshness across renderer }, renderer: { create: ({ engine }) => - createAircraftNavaraRenderer({ - engine, + createAircraftDisplay(engine, { layerId: 'flights', order: 1, headingOffsetDeg: 180, diff --git a/src/layers/flights/render/cesium.js b/src/layers/flights/render/cesium.js deleted file mode 100644 index fd5600696..000000000 --- a/src/layers/flights/render/cesium.js +++ /dev/null @@ -1,23 +0,0 @@ -// The civil flights layer's renderer on Cesium: the shared aircraft renderer -// (src/layers/aircraft/render/cesium.js) with the flights' sprite-order slot -// and airplane.glb's nose offset. The flights' presentation choices (amber -// for known military, the TR-3B glyph, the IR boost as an unlit white -// model) are the layer's logic and reach the renderer as plain data. - -import { createAircraftCesiumRenderer } from '../../aircraft/render/cesium.js'; -import { MODEL_HEADING_OFFSET_DEG } from '../policy.js'; - -/** - * Registry factory (src/app/layers/renderers.js): `(engine, options)`. - * @param {object|null} engine The Cesium globe engine (null in unit tests). - * @param {{ viewer: object, adopt?: object }} options - */ -export function createFlightsCesiumRenderer(engine, { viewer, adopt } = {}) { - return createAircraftCesiumRenderer({ - viewer, - engine, - spriteLayer: 'flights', - headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, - adopt, - }); -} diff --git a/src/layers/flights/render/display.js b/src/layers/flights/render/display.js new file mode 100644 index 000000000..f8424b98c --- /dev/null +++ b/src/layers/flights/render/display.js @@ -0,0 +1,14 @@ +// The civil flights layer's display, the same on every engine: the aircraft +// display (src/layers/aircraft/render/display.js) in the flights' sprite +// slot with airplane.glb's nose offset. The flights' presentation choices +// (amber for known military, the TR-3B glyph, the IR boost as an unlit +// white model) are the layer's logic and reach the display as plain data. + +import { aircraftLayerDisplay } from '../../aircraft/render/display.js'; +import { MODEL_HEADING_OFFSET_DEG } from '../policy.js'; + +/** Registry factory (src/app/layers/renderers.js): `(engine, options)`. */ +export const createFlightsDisplay = aircraftLayerDisplay( + 'flights', + MODEL_HEADING_OFFSET_DEG, +); diff --git a/src/layers/flights/render/navara.js b/src/layers/flights/render/navara.js deleted file mode 100644 index 0183eea96..000000000 --- a/src/layers/flights/render/navara.js +++ /dev/null @@ -1,11 +0,0 @@ -// The civil flights layer's renderer on the Navara engine: the shared -// aircraft renderer with airplane.glb's nose offset. Registered for 'navara' -// by the Navara mode after its import() (src/app/navaraMode.js). - -import { aircraftLayerNavaraRenderer } from '../../aircraft/render/navara.js'; -import { MODEL_HEADING_OFFSET_DEG } from '../policy.js'; - -export const createFlightsNavaraRenderer = aircraftLayerNavaraRenderer( - 'flights', - MODEL_HEADING_OFFSET_DEG, -); diff --git a/src/layers/flights/renderer.test.mjs b/src/layers/flights/renderer.test.mjs index f2d74e16d..e90323ee0 100644 --- a/src/layers/flights/renderer.test.mjs +++ b/src/layers/flights/renderer.test.mjs @@ -1,7 +1,8 @@ // The flights logic draws only through its renderer: with a renderer that // is plain data (no Cesium), the layer builds its fleet, selects a contact // on a click through the engine's selection dispatcher, tracks and follows -// it, and releases it on an empty click. +// it, and releases it on an empty click; then the same through its display +// on the fake engine and on the Cesium engine. import test from 'node:test'; import assert from 'node:assert/strict'; @@ -129,9 +130,9 @@ test('the flights logic draws, selects and follows only through its renderer', a assert.ok(renderer.calls.some(([name]) => name === 'destroy')); }); -test('the flights logic draws and follows on the Navara renderer (fake engine)', async () => { - const { createAircraftNavaraRenderer, aircraftAtlasLayout } = - await import('../aircraft/render/navara.js'); +test('the flights logic draws and follows on the aircraft display (fake engine)', async () => { + const { createAircraftDisplay, aircraftAtlasLayout } = + await import('../aircraft/render/display.js'); const layout = aircraftAtlasLayout(); const engine = createFakeEngine(); let renderer = null; @@ -144,8 +145,7 @@ test('the flights logic draws and follows on the Navara renderer (fake engine)', militaryRegistry, renderer: { create: ({ engine: given }) => - (renderer = createAircraftNavaraRenderer({ - engine: given, + (renderer = createAircraftDisplay(given, { layerId: 'flights', order: 8, headingOffsetDeg: 180, @@ -214,3 +214,82 @@ test('the flights logic draws and follows on the Navara renderer (fake engine)', assert.equal(engine.movers.list().length, 0, 'no mover set left behind'); engine.destroy(); }); + +test('the flights logic draws, tracks and follows on the Cesium engine through the same display', async () => { + const { createCesiumMoversEngine } = + await import('../../testSupport/cesiumMoversEngine.mjs'); + const { createFlightsDisplay } = await import('./render/display.js'); + const { aircraftAtlasLayout } = await import('../aircraft/render/display.js'); + const layout = aircraftAtlasLayout(); + const { engine, viewer, frame, primitive } = createCesiumMoversEngine(); + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(-97.5, 30.3, 400_000), + }); + let display = null; + const layer = createApplicationFlights({ + surface: defaultSurface, + source: { + label: 'Fixture aircraft', + getSnapshot: async () => snapshotOf(['abc123', 'def456']), + }, + militaryRegistry, + renderer: { + // The registry's factory, with the test's atlas. + create: ({ engine: given }) => + (display = createFlightsDisplay(given, { + drawAtlas: async () => ({ + canvas: { width: layout.width, height: layout.height }, + cells: layout.cells, + }), + })), + }, + }); + layer.init(viewer, { engine }); + layer.enable(); + try { + await display.whenReady(); + await layer.update(null); + await new Promise((resolve) => setTimeout(resolve, 0)); + const [sprites] = engine.movers + .list() + .filter(({ id }) => id.startsWith('flights-sprites')); + assert.equal(sprites.count, 2); + const billboards = primitive(sprites.id); + assert.equal(billboards.length, 2, 'one Cesium billboard per contact'); + assert.deepEqual([billboards.get(0).id, billboards.get(1).id].sort(), [ + 'abc123', + 'def456', + ]); + assert.deepEqual( + layer + .qaDrawn() + .map((row) => row.id) + .sort(), + ['abc123', 'def456'], + ); + // Tracking follows through Cesium's trackedEntity, with the readout. + assert.equal(layer.trackById('def456', { origin: 'voice' }), true); + const following = engine.camera.following(); + assert.equal(following?.label?.id, 'flights:def456'); + assert.equal(viewer.trackedEntity?.gevTrackedId, 'flights:def456'); + frame(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const tracked = engine.movers + .list() + .find(({ id }) => id.startsWith('flights-tracked')); + assert.equal(tracked.count, 1); + assert.equal( + primitive(tracked.id).get(0).disableDepthTestDistance, + Number.POSITIVE_INFINITY, + ); + assert.equal(layer.stopTracking(), true); + assert.equal(engine.camera.following(), null); + assert.equal(viewer.trackedEntity, undefined); + } finally { + layer.disable(); + layer.destroy(viewer); + } + assert.equal(engine.movers.list().length, 0, 'no mover set left behind'); + assert.equal(engine.lines.list().length, 0, 'no line left behind'); + engine.destroy(); +}); diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index e40aef935..1308aff95 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -36,9 +36,9 @@ export function createFlightState({ source, services }) { flightState._scratchGroundPos = geo.cartesian3.create(); /** @type {import('../aircraft/render/contract.js').AircraftRenderer|null} - * The engine's aircraft renderer for this lifecycle (render/cesium.js on - * Cesium): the fleet sprites, the models and their collection, the - * tracked visual, the trail, the follow camera and the view reads. */ + * The engine's aircraft renderer for this lifecycle (render/display.js, + * on every engine): the fleet sprites, the models, the tracked visual, + * the trail, the follow camera and the view reads. */ flightState._renderer = null; diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js index 4df72e5e3..4e1d2eb7e 100644 --- a/src/layers/flights/testing.js +++ b/src/layers/flights/testing.js @@ -1,4 +1,4 @@ -import { CYAN_TRANSPARENT } from './policy.js'; +import { CYAN_TRANSPARENT, MODEL_HEADING_OFFSET_DEG } from './policy.js'; import { CYAN } from '../shared/color.js'; export function createTesting({ @@ -47,6 +47,8 @@ export function createTesting({ * @param {object} state.billboard * @param {object} state.billboardCollection * @param {object} state.viewer + * @param {(options: object) => object} state.renderer The test's renderer + * over the viewer and collections (testSupport/aircraftViewerRenderer). * @param {Array} [state.history=[]] * @param {boolean} [state.tracked=true] * @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed @@ -64,11 +66,14 @@ export function createTesting({ tracked = true, models = [], modelCollection = null, + renderer: createRenderer, }) { - // The production renderer over the test's viewer and (fake) collections. - flightState._renderer = services.renderer.create({ + // The test's renderer over its viewer and (fake) collections. + flightState._renderer = createRenderer({ viewer, - adopt: { billboardCollection, modelCollection }, + billboardCollection, + modelCollection, + headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, }); flightState.enabled = flightState._renderer.fleet.visible(); flightState._billboards = new Map([[icao24, billboard]]); diff --git a/src/layers/installations/render/cesium/lifecycle.js b/src/layers/installations/render/cesium/lifecycle.js index 9f9672ced..6f3d75405 100644 --- a/src/layers/installations/render/cesium/lifecycle.js +++ b/src/layers/installations/render/cesium/lifecycle.js @@ -1,6 +1,5 @@ import * as Cesium from 'cesium'; import { LAYER_ID } from '../../policy.js'; -import { engineFromViewer } from '../../../../globe/cesium/engineFromViewer.js'; import { claimCameraSensitivity, releaseCameraSensitivity, @@ -14,10 +13,10 @@ export function createLifecycle({ }) { const { registerPickOwner, unregisterPickOwner } = services.picking; const { clearSelectedEntityContextForLayer } = services.context; + const cameraOwner = (viewer) => layerState.engine ?? viewer?.camera; const methods = { init(viewer, { engine = null } = {}) { - engine ||= engineFromViewer(viewer); layerState.viewer = viewer; layerState.engine = engine; layerState.dataSource = new Cesium.CustomDataSource( @@ -55,7 +54,7 @@ export function createLifecycle({ }); parts.namedMarkers?.enable(); parts.namedMarkers?.sync(layerState.records || []); - claimCameraSensitivity(layerState.viewer.camera, LAYER_ID, 0.01); + claimCameraSensitivity(cameraOwner(layerState.viewer), LAYER_ID, 0.01); registerPickOwner(LAYER_ID, (id) => layerState.recordById.has(id)); layerState.dataSource.show = true; // DataLayerManager invokes update() immediately after enable(), which owns @@ -67,7 +66,7 @@ export function createLifecycle({ services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID); services.credits?.hideGooglePlacesCredit?.(layerState.viewer, LAYER_ID); parts.namedMarkers?.hide(); - releaseCameraSensitivity(layerState.viewer?.camera, LAYER_ID); + releaseCameraSensitivity(cameraOwner(layerState.viewer), LAYER_ID); layerState.contextAnchor = null; layerState.contextPosition = null; layerState.coverage = { kind: 'viewport' }; @@ -99,7 +98,7 @@ export function createLifecycle({ services.credits?.hideOsmCredit?.(viewer, LAYER_ID); services.credits?.hideGooglePlacesCredit?.(viewer, LAYER_ID); unregisterPickOwner(LAYER_ID); - releaseCameraSensitivity(viewer?.camera, LAYER_ID); + releaseCameraSensitivity(cameraOwner(viewer), LAYER_ID); layerState.surfaceChangedRemove?.(); layerState.surfaceChangedRemove = null; layerState.moveEndRemove?.(); diff --git a/src/layers/localAdsb/edgeCases.test.mjs b/src/layers/localAdsb/edgeCases.test.mjs index 2be76890f..f81c4f73c 100644 --- a/src/layers/localAdsb/edgeCases.test.mjs +++ b/src/layers/localAdsb/edgeCases.test.mjs @@ -8,8 +8,10 @@ import * as Cesium from 'cesium'; import { createLocalAdsbLayer } from './index.js'; import { createLocalReceiverFeeds } from './feeds.js'; -import { createLocalAdsbCesiumRenderer } from './render/cesium.js'; +import { aircraftAtlasLayout } from '../aircraft/render/display.js'; +import { createLocalAdsbDisplay } from './render/display.js'; import * as selection from '../../data/selectionDispatcher.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { readShareFollowSubject } from '../../ui/shareFollow.js'; const flush = () => new Promise((resolve) => setImmediate(resolve)); @@ -81,13 +83,20 @@ function fakeFeeds() { }; } -function fakeServices() { +const layout = aircraftAtlasLayout(); +const drawAtlas = async () => ({ + canvas: { width: layout.width, height: layout.height }, + cells: layout.cells, +}); + +function fakeServices(registered = []) { let selected = null; return { render: { governorRequestRender() {} }, context: { registerEntityContext(entity, metadata) { entity.__gevContextId = metadata.id; + if (!registered.includes(entity)) registered.push(entity); }, selectEntityContext(entity) { selected = { id: entity.__gevContextId, entity }; @@ -101,40 +110,15 @@ function fakeServices() { }, picking: { registerPickOwner() {}, unregisterPickOwner() {} }, selection, + // The layer's display (on a fake engine). renderer: { - create: ({ engine = null, ...options }) => - createLocalAdsbCesiumRenderer(engine, options), + create: ({ engine }) => createLocalAdsbDisplay(engine, { drawAtlas }), }, detection: { markSourcesChanged() {} }, overlays: { refreshReadout() {} }, }; } -function fakeViewer() { - const sources = []; - const camera = { - rightWC: new Cesium.Cartesian3(0, 1, 0), - upWC: new Cesium.Cartesian3(0, 0, 1), - }; - return { - sources, - viewer: { - camera, - scene: { camera }, - entities: { - add: (entity) => entity, - remove() {}, - }, - dataSources: { - add(source) { - sources.push(source); - }, - remove() {}, - }, - }, - }; -} - function record(overrides = {}) { return { icao: 'abc123', @@ -165,7 +149,7 @@ test('edge:local-adsb: an unplugged receiver ends one session and a scene switch services: fakeServices(), now: () => clock.now, }); - layer.init(fakeViewer().viewer); + layer.init(null, { engine: createFakeEngine() }); await layer.enable(); assert.ok(layer.selectAircraft('abc123')); @@ -183,7 +167,7 @@ test('edge:local-adsb: an unplugged receiver ends one session and a scene switch layer.detachRenderer(); clock.now += 1_000; await layer.update(); - layer.attachRenderer(fakeViewer().viewer); + layer.attachRenderer(null, { engine: createFakeEngine() }); await layer.update(); assert.deepEqual(receiver.modeCalls, ['adsb'], 'the tuner is asked once'); @@ -241,22 +225,20 @@ test('edge:local-adsb: a selected local aircraft never reaches a share link', as mode: 'adsb', aircraft: [record({ lat: 10, lon: 20 })], }); - const { viewer, sources } = fakeViewer(); + const registered = []; const layer = createLocalAdsbLayer({ receiver, feeds: fakeFeeds(), - services: fakeServices(), + services: fakeServices(registered), now: () => 100_000, }); t.after(() => layer.destroy()); - layer.init(viewer); + layer.init(null, { engine: createFakeEngine() }); await layer.enable(); assert.ok(layer.selectAircraft('abc123')); - const marker = sources - .flatMap((source) => source.entities.values) - .find((entity) => - String(entity.gevTrackedId || '').startsWith('local-adsb:'), - ); + const marker = registered.find((entity) => + String(entity.gevTrackedId || '').startsWith('local-adsb:'), + ); assert.ok(marker, 'the local marker carries its tracked id'); const shareViewer = { diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index a84ed09c3..7201490f9 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -78,17 +78,14 @@ const DEFAULT_DISPLAY = Object.freeze({ * @param {object} [options.feeds] Decoder-feed session (start, stop, * getState, subscribe). * @param {object} options.services Render, context, picking, selection - * (`selectionDispatcherFor`), renderer (`create({ viewer, engine, - * loadModel, createTrail })`: the engine's Local ADS-B renderer), - * detection and readout operations supplied by the application; - * optionally `display` (`getParams()` → `{ models3d, models3dMode }`), - * `enrichment` (a source with `getEnrichment`), `trails` (`createTrail`), - * `geoid` (`ensureGeoidReady`, `geoidHeight`) and `groundSnap` - * (`createGroundSnap`). + * (`selectionDispatcherFor`), renderer (`create({ viewer, engine })`: the + * engine's Local ADS-B display), detection and readout operations + * supplied by the application; optionally `display` (`getParams()` → + * `{ models3d, models3dMode }`), `enrichment` (a source with + * `getEnrichment`), `geoid` (`ensureGeoidReady`, `geoidHeight`) and + * `groundSnap` (`createGroundSnap`). * @param {() => number} [options.now] Clock, injectable for tests. * @param {(url: string) => string} [options.resolveAsset] Model URL resolver. - * @param {(options: object) => Promise} [options.loadModel] Model - * loader, injectable for tests. * @returns {object} Data-layer module. */ export function createLocalAdsbLayer({ @@ -97,7 +94,6 @@ export function createLocalAdsbLayer({ services, now = Date.now, resolveAsset = (url) => url, - loadModel, }) { if (typeof receiver?.getState !== 'function') throw new TypeError('Local ADS-B requires a receiver session'); @@ -115,7 +111,7 @@ export function createLocalAdsbLayer({ } = services.context; const { registerPickOwner, unregisterPickOwner } = services.picking; - /** The engine's Local ADS-B renderer for this lifecycle (render/*.js). */ + /** The engine's Local ADS-B display for this lifecycle (render/display.js). */ let renderer = null; let enabled = false; let unsubscribe = null; @@ -637,12 +633,7 @@ export function createLocalAdsbLayer({ }, attachRenderer(nextViewer, { engine = null } = {}) { - renderer = services.renderer.create({ - viewer: nextViewer, - engine, - loadModel, - createTrail: services.trails?.createTrail ?? null, - }); + renderer = services.renderer.create({ viewer: nextViewer, engine }); renderer.attach(); if (renderer.models) { models = createLocalAdsbModels({ diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs index 7edcba783..219042ae5 100644 --- a/src/layers/localAdsb/index.test.mjs +++ b/src/layers/localAdsb/index.test.mjs @@ -12,10 +12,12 @@ import { localAdsbReceiverLine, localAdsbStatus, } from './index.js'; -import { createLocalAdsbCesiumRenderer } from './render/cesium.js'; +import { createLocalAdsbDisplay } from './render/display.js'; +import { aircraftAtlasLayout } from '../aircraft/render/display.js'; import * as selection from '../../data/selectionDispatcher.js'; -import { registerViewerEngine } from '../../globe/cesium/engineFromViewer.js'; import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import * as geo from '../../geo/index.js'; +import { rgbaFromCss } from '../shared/color.js'; import { registerPickOwner, unregisterPickOwner, @@ -54,8 +56,29 @@ function fakeReceiver(initial = {}) { }; } -function fakeServices() { - const calls = { registered: [], selected: [], cleared: [], removed: [] }; +const layout = aircraftAtlasLayout(); +const drawAtlas = async () => ({ + canvas: { width: layout.width, height: layout.height }, + cells: layout.cells, +}); +const MAGENTA = rgbaFromCss('#ff4fd8').rgba(); +const rgba = (color) => [color.red, color.green, color.blue, color.alpha]; +const near = (a, b, label) => + assert.ok( + a.every((value, index) => Math.abs(value - b[index]) < 1e-6), + `${label}: ${a} vs ${b}`, + ); +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +function fakeServices({ display = (engine) => engine } = {}) { + // Marker handles by id, as the layer registers their context. + const calls = { + registered: [], + selected: [], + cleared: [], + removed: [], + markers: new Map(), + }; let selected = null; return { calls, @@ -65,6 +88,7 @@ function fakeServices() { registerEntityContext(entity, metadata) { entity.__gevContextId = metadata.id; calls.registered.push(metadata); + calls.markers.set(metadata.id, entity); }, selectEntityContext(entity) { selected = { id: entity.__gevContextId, entity }; @@ -81,12 +105,12 @@ function fakeServices() { }, }, picking: { registerPickOwner() {}, unregisterPickOwner() {} }, - // As src/app/layers/localAdsb.js composes them: the layer's Cesium - // renderer, and the engine's selection dispatcher (clicks). + // As src/app/layers/localAdsb.js composes them: the layer's display + // (on a fake engine), and the engine's selection dispatcher (clicks). selection, renderer: { - create: ({ engine = null, ...options }) => - createLocalAdsbCesiumRenderer(engine, options), + create: ({ engine }) => + display(createLocalAdsbDisplay(engine, { drawAtlas })), }, detection: { markSourcesChanged() {} }, overlays: { refreshReadout() {} }, @@ -94,28 +118,29 @@ function fakeServices() { }; } -function fakeViewer() { - const sources = []; - const camera = { - rightWC: new Cesium.Cartesian3(0, 1, 0), - upWC: new Cesium.Cartesian3(0, 0, 1), - }; +/** A fake engine whose camera looks down on (0°, 0°) from 50 km. */ +function fakeEngine() { + const engine = createFakeEngine(); + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(0, 0, 50_000), + }); + return engine; +} + +/** The ids of the markers the display draws, and its sprite and ring sets. */ +function drawn(engine) { + const sets = engine.movers.list(); + const sprites = sets.find(({ id }) => id.startsWith('local-adsb-sprites')); + const rings = sets.find(({ id }) => id.startsWith('local-adsb-rings')); + const models = sets.find(({ id }) => id.startsWith('local-adsb-models')); + const set = (listed) => (listed ? engine.fake.moverSet(listed.id) : null); return { - sources, - viewer: { - camera, - scene: { camera }, - entities: { - add: (entity) => entity, - remove() {}, - }, - dataSources: { - add(source) { - sources.push(source); - }, - remove() {}, - }, - }, + ids: sprites ? set(sprites).store.pickIds.slice(0, sprites.count) : [], + sprites: set(sprites), + rings: set(rings), + models: set(models), + count: (listed) => listed?.count ?? 0, + listed: { sprites, rings, models }, }; } @@ -171,52 +196,19 @@ function fakeFeeds(initial = {}) { }; } -/** A viewer whose camera looks down on (0°, 0°) from 50 km, with primitives. */ -function modelViewer(primitives) { - const base = fakeViewer(); - const camera = { - ...base.viewer.camera, - positionWC: new Cesium.Cartesian3(6_378_137 + 50_000, 0, 0), - positionCartographic: new Cesium.Cartographic(0, 0, 50_000), - directionWC: new Cesium.Cartesian3(-1, 0, 0), - frustum: { - computeCullingVolume: () => ({ - computeVisibility: () => Cesium.Intersect.INSIDE, - }), - }, - }; - return { - ...base, - viewer: { - ...base.viewer, - camera, - scene: { - camera, - primitives: { - add(collection) { - primitives.push(collection); - return collection; - }, - remove() {}, - }, - }, - }, - }; -} - async function enabledLayer(receiver, clock, feeds = null, options = {}) { - const { services, calls } = fakeServices(); - const { sources, viewer } = options.viewer || fakeViewer(); + const { services, calls } = fakeServices(options); + const engine = options.engine || fakeEngine(); const layer = createLocalAdsbLayer({ receiver, feeds, services: { ...services, ...options.services }, now: () => clock.now, - loadModel: options.loadModel, }); - layer.init(viewer); + layer.init(null, { engine }); await layer.enable(); - return { layer, calls, sources }; + const marker = (icao) => calls.markers.get(`local-adsb:${icao}`); + return { layer, calls, engine, marker }; } test('enabling Local ADS-B asks the shared tuner for 1090 MHz; disabling leaves it alone', async (t) => { @@ -238,43 +230,38 @@ test('enabling Local ADS-B asks the shared tuner for 1090 MHz; disabling leaves test('local aircraft use their class silhouette and scale in magenta', async (t) => { const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const clock = { now: 100_000 }; - const { layer, sources } = await enabledLayer(receiver, clock); + const { layer, engine, marker } = await enabledLayer(receiver, clock); t.after(() => layer.destroy()); receiver.set({ aircraft: [record(), record({ icao: 'a0b702', category: 'A7' })], }); await layer.update(); + await settle(); - const entity = sources[0].entities.getById('local-adsb:abc123'); + const entity = marker('abc123'); assert.ok(entity); - assert.equal(entity.point, undefined); - assert.equal(entity.label, undefined); - assert.equal(entity.billboard.image.getValue(), aircraftIcon('airliner')); - assert.equal(entity.billboard.width.getValue(), 20); - assert.ok( - Cesium.Color.equals( - entity.billboard.color.getValue(), - Cesium.Color.fromCssColorString('#ff4fd8'), - ), + assert.equal(entity.ring, null, 'no UAT ring'); + assert.equal(entity.sprite.image, aircraftIcon('airliner')); + assert.equal(entity.sprite.width, 20); + near(rgba(entity.sprite.color), MAGENTA, 'magenta'); + // Screen-aligned glyphs over the scene, scaled with distance as Cesium's + // billboards were. + const { sprites } = drawn(engine); + assert.equal(sprites.options.depthTest, 'always'); + assert.deepEqual( + sprites.options.sprite.scaleByDistance, + [1000, 3, 8_000_000, 0.5], ); - assert.ok( - Cesium.Cartesian3.equals( - entity.billboard.alignedAxis.getValue(), - Cesium.Cartesian3.ZERO, - ), - ); - assert.equal(entity.billboard.rotation.getValue(), 0); + assert.equal(entity.spec.rotation(), 0); assert.equal(entity.gevLabelModel.title, 'LOCAL1'); assert.equal(typeof entity.gevDisplayPosition, 'function'); - const heli = sources[0].entities.getById('local-adsb:a0b702'); - assert.equal(heli.billboard.image.getValue(), aircraftIcon('helicopter')); - assert.equal(heli.billboard.scale.getValue(), CLASS_SCALE_2D.helicopter); - assert.ok( - Cesium.Color.equals( - heli.billboard.color.getValue(), - Cesium.Color.fromCssColorString('#ff4fd8'), - ), + const heli = marker('a0b702'); + assert.equal(heli.sprite.image, aircraftIcon('helicopter')); + assert.equal(heli.sprite.scale, CLASS_SCALE_2D.helicopter); + near( + rgba(heli.sprite.color), + MAGENTA, 'the helicopter keeps the local magenta', ); assert.equal(heli.gevLabelModel.details[1], 'Helicopter · A7'); @@ -291,10 +278,7 @@ test('local aircraft use their class silhouette and scale in magenta', async (t) ], }); await layer.update(); - assert.equal( - heli.billboard.scale.getValue(), - CLASS_SCALE_2D.helicopter * 0.8, - ); + assert.equal(heli.sprite.scale, CLASS_SCALE_2D.helicopter * 0.8); const [detectable] = layer.getDetectableObjects(); assert.equal(detectable.sourceId, 'a0b702'); @@ -304,12 +288,12 @@ test('local aircraft use their class silhouette and scale in magenta', async (t) test('the heading slews toward a new track instead of snapping', async (t) => { const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const clock = { now: 100_000 }; - const { layer, sources } = await enabledLayer(receiver, clock); + const { layer, marker } = await enabledLayer(receiver, clock); t.after(() => layer.destroy()); receiver.set({ aircraft: [record()] }); await layer.update(); - const entity = sources[0].entities.getById('local-adsb:abc123'); - assert.equal(entity.billboard.rotation.getValue(), 0); + const entity = marker('abc123'); + assert.equal(entity.spec.rotation(), 0); clock.now = 101_000; receiver.set({ @@ -323,7 +307,7 @@ test('the heading slews toward a new track instead of snapping', async (t) => { ], }); await layer.update(); - const first = entity.billboard.rotation.getValue(); + const first = entity.spec.rotation(); assert.ok( first > -Cesium.Math.PI_OVER_TWO && first <= 0, 'one frame does not swing the nose 90°', @@ -333,8 +317,7 @@ test('the heading slews toward a new track instead of snapping', async (t) => { await layer.update(); } assert.ok( - Math.abs(entity.billboard.rotation.getValue() + Cesium.Math.PI_OVER_TWO) < - 1e-6, + Math.abs(entity.spec.rotation() + Cesium.Math.PI_OVER_TWO) < 1e-6, 'the nose settles on the new track', ); }); @@ -342,13 +325,12 @@ test('the heading slews toward a new track instead of snapping', async (t) => { test('markers move in real time between fixes and never jump on a new fix', async (t) => { const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const clock = { now: 100_000 }; - const { layer, sources } = await enabledLayer(receiver, clock); + const { layer, marker } = await enabledLayer(receiver, clock); t.after(() => layer.destroy()); const latitude = () => Cesium.Math.toDegrees( - Cesium.Cartographic.fromCartesian( - sources[0].entities.getById('local-adsb:abc123').position.getValue(), - ).latitude, + Cesium.Cartographic.fromCartesian(marker('abc123').spec.position()) + .latitude, ); // 180 kt due north: 1 s covers 92.6 m, 0.000833° of latitude. const degPerSecond = (180 * 1852) / 3600 / 111_195; @@ -386,7 +368,7 @@ test('markers move in real time between fixes and never jump on a new fix', asyn test('an implausible jump in a feed record is ignored by the display', async (t) => { const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const clock = { now: 100_000 }; - const { layer, sources } = await enabledLayer(receiver, clock); + const { layer, marker } = await enabledLayer(receiver, clock); t.after(() => layer.destroy()); receiver.set({ aircraft: [record({ groundSpeedKt: 120, trackDeg: 0 })] }); await layer.update(); @@ -403,9 +385,8 @@ test('an implausible jump in a feed record is ignored by the display', async (t) }); await layer.update(); const lat = Cesium.Math.toDegrees( - Cesium.Cartographic.fromCartesian( - sources[0].entities.getById('local-adsb:abc123').position.getValue(), - ).latitude, + Cesium.Cartographic.fromCartesian(marker('abc123').spec.position()) + .latitude, ); assert.ok(lat < 0.001, `stayed on its track (${lat})`); assert.equal(layer.getStats().rejectedPositions, 1); @@ -414,22 +395,14 @@ test('an implausible jump in a feed record is ignored by the display', async (t) test('selecting an aircraft draws a magenta trail of the fixes the receiver heard', async (t) => { const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const clock = { now: 100_000 }; - const trails = []; - const { layer } = await enabledLayer(receiver, clock, null, { - services: { - trails: { - createTrail(viewer, options) { - const trail = { options, positions: [], destroyed: false }; - trails.push(trail); - return { - setPositions: (positions) => (trail.positions = positions), - destroy: () => (trail.destroyed = true), - }; - }, - }, - }, - }); + const { layer, engine } = await enabledLayer(receiver, clock); t.after(() => layer.destroy()); + // The trail body (the head is its own line, `-head-`). + const trails = () => + engine.lines + .list() + .filter(({ id }) => /^gev-trail:local-adsb-\d/.test(id)) + .map(({ id }) => engine.fake.line(id)); for (let second = 0; second < 4; second += 1) { clock.now = 100_000 + second * 1_000; receiver.set({ @@ -444,25 +417,36 @@ test('selecting an aircraft draws a magenta trail of the fixes the receiver hear await layer.update(); if (second === 1) assert.equal(layer.selectAircraft('abc123'), true); } - assert.equal(trails.length, 1); - assert.equal(trails[0].options.color, '#ff4fd8'); - assert.equal(trails[0].options.width, 2.5); - assert.equal(trails[0].positions.length, 4, 'every heard fix, oldest first'); - assert.ok( - Cesium.Cartographic.fromCartesian(trails[0].positions[0]).latitude < - Cesium.Cartographic.fromCartesian(trails[0].positions[3]).latitude, - ); + const [trail] = trails(); + assert.equal(trails().length, 1); + near(trail.options.color.slice(0, 3), MAGENTA.slice(0, 3), 'magenta'); + assert.equal(trail.options.widthPx, 2.5); + const flat = trail.positions; + assert.equal(flat.length / 3, 4, 'every heard fix, oldest first'); + const at = (index) => + Cesium.Cartographic.fromCartesian( + new Cesium.Cartesian3( + flat[index * 3], + flat[index * 3 + 1], + flat[index * 3 + 2], + ), + ); + assert.ok(at(0).latitude < at(3).latitude); clock.now = 200_000; await layer.update(); - assert.equal(trails[0].destroyed, true, 'the trail leaves with the aircraft'); + assert.equal(trails().length, 0, 'the trail leaves with the aircraft'); }); test('adsbdb type, operator and route reach the card for the selected aircraft only', async (t) => { const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const clock = { now: 100_000 }; const queries = []; - const { layer, sources } = await enabledLayer(receiver, clock, null, { + const { layer, marker } = await enabledLayer(receiver, clock, null, { services: { + // 3D off: a model-eligible aircraft is enriched for its class too. + display: { + getParams: () => ({ models3d: false, models3dMode: 'proximity' }), + }, enrichment: { async getEnrichment(query) { queries.push(`${query.kind}:${query.id}`); @@ -490,7 +474,7 @@ test('adsbdb type, operator and route reach the card for the selected aircraft o assert.equal(layer.selectAircraft('a0b702'), true); await new Promise((resolve) => setTimeout(resolve, 400)); assert.deepEqual([...queries].sort(), ['route:EVC145', 'type:a0b702']); - const entity = sources[0].entities.getById('local-adsb:a0b702'); + const entity = marker('a0b702'); assert.deepEqual(entity.gevLabelModel.details.slice(0, 4), [ 'ICAO A0B702 · EVC145', 'Helicopter · A7', @@ -509,76 +493,48 @@ test('adsbdb type, operator and route reach the card for the selected aircraft o test('the DISPLAY 3D toggle gives local aircraft magenta class models', async (t) => { const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const clock = { now: 100_000 }; - const loads = []; const display = { models3d: true, models3dMode: 'proximity' }; - const primitives = []; - const { layer, sources } = await enabledLayer(receiver, clock, null, { + const { layer, engine, marker } = await enabledLayer(receiver, clock, null, { services: { display: { getParams: () => display } }, - viewer: modelViewer(primitives), - loadModel: async (options) => { - const model = { - ...options, - ready: true, - show: true, - modelMatrix: new Cesium.Matrix4(), - destroyed: false, - destroy() { - this.destroyed = true; - }, - isDestroyed() { - return this.destroyed; - }, - update() {}, - }; - loads.push(model); - return model; - }, }); t.after(() => layer.destroy()); receiver.set({ aircraft: [record({ icao: 'a0b702', category: 'A7' })] }); await layer.update(); - await new Promise((resolve) => setImmediate(resolve)); + await settle(); await layer.update(); - assert.equal(loads.length, 1); - const [model] = loads; + await settle(); + const { models } = drawn(engine); + assert.ok(models, 'a model set'); assert.equal( - model.url, + models.options.model.url, '/models/bell206.glb', 'the Flights helicopter model', ); - assert.equal(model.scale, 1); - assert.equal(model.id, 'local-adsb:a0b702'); - assert.ok( - Cesium.Color.equals( - model.color, - Cesium.Color.fromCssColorString('#ff4fd8'), - ), - ); - assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.MIX); - assert.equal(model.colorBlendAmount, 0.94); - assert.equal(model.show, true); - const heli = sources[0].entities.getById('local-adsb:a0b702'); - assert.equal( - heli.billboard.show.getValue(), - false, - 'the model is the visual', - ); - const origin = Cesium.Matrix4.getTranslation( - model.modelMatrix, - new Cesium.Cartesian3(), - ); - assert.ok(Cesium.Cartesian3.distance(origin, heli.position.getValue()) < 1); + assert.deepEqual(models.options.model.colorBlend, { + mode: 'mix', + amount: 0.94, + }); + assert.equal(models.store.count, 1); + assert.equal(models.store.scales[0], 1); + assert.equal(models.store.pickIds[0], 'local-adsb:a0b702'); + near([...models.store.colors.slice(0, 4)], MAGENTA, 'magenta'); + assert.equal(models.store.shows[0], 1, 'the model is drawn'); + const heli = marker('a0b702'); + assert.equal(heli.spec.show(), false, 'the model is the visual'); + const origin = new Cesium.Cartesian3(...models.store.positions.slice(0, 3)); + assert.ok(Cesium.Cartesian3.distance(origin, heli.spec.position()) < 1); display.models3d = false; await layer.update(); - assert.equal(model.destroyed || !primitives[0].contains(model), true); - assert.equal(heli.billboard.show.getValue(), true, 'the billboard is back'); + await settle(); + assert.equal(drawn(engine).models.store.count, 0, 'the model is gone'); + assert.equal(heli.spec.show(), true, 'the billboard is back'); }); test('markers drop when the position is 60 s old and records when silent 60 s', async (t) => { const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const clock = { now: 100_000 }; - const { layer, sources } = await enabledLayer(receiver, clock); + const { layer, engine } = await enabledLayer(receiver, clock); t.after(() => layer.destroy()); receiver.set({ aircraft: [ @@ -593,7 +549,8 @@ test('markers drop when the position is 60 s old and records when silent 60 s', ], }); await layer.update(); - const entities = () => sources[0].entities.values.map((entity) => entity.id); + await settle(); + const entities = () => drawn(engine).ids; assert.deepEqual( entities(), ['local-adsb:abc123'], @@ -608,6 +565,7 @@ test('markers drop when the position is 60 s old and records when silent 60 s', assert.deepEqual(entities(), ['local-adsb:abc123']); clock.now = 160_000; await layer.update(); + await settle(); assert.deepEqual(entities(), [], 'a stale position is not drawn'); assert.deepEqual(layer.getDetectableObjects(), []); }); @@ -765,7 +723,7 @@ test('feed records merge with browser SDR records by ICAO and UAT-only aircraft const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const feeds = fakeFeeds(); const clock = { now: 100_000 }; - const { layer, sources } = await enabledLayer(receiver, clock, feeds); + const { layer, engine, marker } = await enabledLayer(receiver, clock, feeds); t.after(() => layer.destroy()); assert.deepEqual(feeds.calls, ['start'], 'the feeds poll while enabled'); @@ -784,33 +742,29 @@ test('feed records merge with browser SDR records by ICAO and UAT-only aircraft ], }); await layer.update(); - const shared = sources[0].entities.getById('local-adsb:abc123'); - const uat = sources[0].entities.getById('local-adsb:a1b2c3'); - assert.equal(sources[0].entities.values.length, 2); + await settle(); + const shared = marker('abc123'); + const uat = marker('a1b2c3'); + assert.equal(drawn(engine).ids.length, 2); assert.equal( - Cesium.Cartographic.fromCartesian( - shared.position.getValue(), - ).latitude.toFixed(4), + Cesium.Cartographic.fromCartesian(shared.spec.position()).latitude.toFixed( + 4, + ), Cesium.Math.toRadians(1).toFixed(4), 'the newer feed position wins', ); - assert.equal(shared.point, undefined, 'heard on 1090 too: no UAT ring'); + assert.equal(shared.ring, null, 'heard on 1090 too: no UAT ring'); assert.equal( shared.gevLabelModel.details.at(-1), 'Heard by your receiver · 1090 MHz + 978 MHz UAT · browser SDR + decoder feed', ); - assert.ok(uat.point, 'a UAT-only aircraft carries the ring'); - assert.equal(uat.point.outlineWidth.getValue(), 1.5); - assert.ok( - Cesium.Color.equals(uat.point.color.getValue(), Cesium.Color.TRANSPARENT), - ); - assert.ok( - Cesium.Color.equals( - uat.billboard.color.getValue(), - Cesium.Color.fromCssColorString('#ff4fd8'), - ), - 'same magenta marker family', - ); + assert.ok(uat.ring, 'a UAT-only aircraft carries the ring'); + const { rings } = drawn(engine); + assert.equal(rings.store.count, 1); + assert.equal(rings.store.pickIds[0], 'local-adsb:a1b2c3'); + assert.equal(rings.options.point.outlinePx, 1.5); + assert.deepEqual([...rings.store.colors.slice(0, 4)], [0, 0, 0, 0]); + near(rgba(uat.sprite.color), MAGENTA, 'same magenta marker family'); assert.equal( uat.gevLabelModel.details.at(-1), 'Heard by your receiver · 978 MHz UAT · decoder feed', @@ -1009,60 +963,50 @@ test('row status names two same-band feeds by their ordinal labels', () => { ); }); -function fakeModel(options) { - return { - ...options, - ready: true, - show: true, - modelMatrix: new Cesium.Matrix4(), - destroyed: false, - destroy() { - this.destroyed = true; - }, - isDestroyed() { - return this.destroyed; - }, - update() {}, - }; -} - test('the last aircraft expiring removes its 3D model and cancels a pending load', async (t) => { const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const clock = { now: 100_000 }; - const primitives = []; const loads = []; const deferred = []; - const { layer } = await enabledLayer(receiver, clock, null, { + // The display's loads, the second held until the test lets it finish. + const holdLoads = (display) => { + const load = display.models.loadFleet; + display.models.loadFleet = async (spec) => { + const model = await load(spec); + loads.push(model); + if (spec.id === 'local-adsb:abc123') return model; + return new Promise((resolve) => deferred.push(() => resolve(model))); + }; + return display; + }; + const { layer, engine } = await enabledLayer(receiver, clock, null, { + display: holdLoads, services: { display: { getParams: () => ({ models3d: true, models3dMode: 'proximity' }), }, }, - viewer: modelViewer(primitives), - loadModel: (options) => { - const model = fakeModel(options); - loads.push(model); - if (options.id === 'local-adsb:abc123') return Promise.resolve(model); - return new Promise((resolve) => deferred.push(() => resolve(model))); - }, }); t.after(() => layer.destroy()); receiver.set({ aircraft: [record({ category: 'A7' })] }); await layer.update(); - await new Promise((resolve) => setImmediate(resolve)); + await settle(); await layer.update(); + await settle(); const [model] = loads; assert.equal(model.show, true, 'the model is the visual'); - assert.ok(primitives[0].contains(model)); + assert.equal(drawn(engine).models.store.count, 1); // Its position ages out: no markers remain, so no per-frame model pass. clock.now = 160_000; await layer.update(); + await settle(); assert.equal( - model.destroyed || !primitives[0].contains(model), - true, + drawn(engine).models.store.count, + 0, 'the expired aircraft leaves no model behind', ); + assert.equal(model.gevBank, null); // A load still in flight when its aircraft expires is never admitted. clock.now = 200_000; @@ -1076,34 +1020,31 @@ test('the last aircraft expiring removes its 3D model and cancels a pending load ], }); await layer.update(); + await settle(); clock.now = 260_000; await layer.update(); assert.equal(deferred.length, 1, 'a load was in flight'); deferred[0](); - await new Promise((resolve) => setImmediate(resolve)); + await settle(); const late = loads.at(-1); assert.equal(late.id, 'local-adsb:def456'); - assert.equal(primitives[0].contains(late), false); - assert.equal(late.destroyed, true); + assert.equal(late.gevBank, null, 'never admitted'); + assert.equal(drawn(engine).models.store.count, 0); }); /** - * A viewer whose engine (a fake globe engine registered for it) scripts the - * picks under a click; clicks reach the layer through the engine's - * selection dispatcher, as in the app. + * A fake engine that scripts the picks under a click; clicks reach the layer + * through the engine's selection dispatcher, as in the app. */ function clickHarness() { - const base = fakeViewer(); - const engine = createFakeEngine(); + const engine = fakeEngine(); const scene = { pickResult: null, drillResult: [] }; engine.fake.script('pick', () => scene.pickResult ?? undefined); engine.fake.script('drillPick', () => scene.drillResult); - const unregister = registerViewerEngine(base.viewer, engine); return { scene, - viewer: base, + engine, release() { - unregister(); engine.destroy(); }, click() { @@ -1119,7 +1060,7 @@ test('clicking the selected aircraft keeps it selected and republishes its card' const clock = { now: 100_000 }; const harness = clickHarness(); const { layer, calls } = await enabledLayer(receiver, clock, null, { - viewer: harness.viewer, + engine: harness.engine, }); t.after(() => { layer.destroy(); @@ -1127,9 +1068,8 @@ test('clicking the selected aircraft keeps it selected and republishes its card' }); receiver.set({ aircraft: [record()] }); await layer.update(); - const entity = - harness.viewer.sources[0].entities.getById('local-adsb:abc123'); - harness.scene.pickResult = { id: entity, primitive: {} }; + // The engine names the marker's sprite by its pick id. + harness.scene.pickResult = { id: 'local-adsb:abc123' }; harness.click(); assert.deepEqual(calls.selected, ['local-adsb:abc123']); // Its card may have been displaced (another readout, a camera move): a @@ -1144,7 +1084,7 @@ test('a click on a local aircraft under an unowned or trail pick still selects i const clock = { now: 100_000 }; const harness = clickHarness(); const { layer, calls } = await enabledLayer(receiver, clock, null, { - viewer: harness.viewer, + engine: harness.engine, }); // The app's pick owners: the trails, and a Flights contact. registerPickOwner('trails', (id) => String(id).startsWith('gev-trail:')); @@ -1159,33 +1099,27 @@ test('a click on a local aircraft under an unowned or trail pick still selects i aircraft: [record(), record({ icao: 'def456', lat: 0.01 })], }); await layer.update(); - const [first, second] = ['abc123', 'def456'].map((icao) => - harness.viewer.sources[0].entities.getById(`local-adsb:${icao}`), + const [first, second] = ['abc123', 'def456'].map( + (icao) => `local-adsb:${icao}`, ); // A photoreal tile (no pick id) drawn over the 3D model at this pixel. harness.scene.pickResult = { primitive: { tileset: true } }; harness.scene.drillResult = [ harness.scene.pickResult, - { primitive: { id: 'local-adsb:abc123' }, id: 'local-adsb:abc123' }, + { id: 'local-adsb:abc123' }, ]; harness.click(); assert.deepEqual(calls.selected, ['local-adsb:abc123']); // The selected aircraft's trail crosses another local aircraft's marker. - harness.scene.pickResult = { id: { id: 'gev-trail:local-adsb-head-1' } }; - harness.scene.drillResult = [ - harness.scene.pickResult, - { id: second, primitive: {} }, - ]; + harness.scene.pickResult = { id: 'gev-trail:local-adsb-head-1' }; + harness.scene.drillResult = [harness.scene.pickResult, { id: second }]; harness.click(); assert.deepEqual(calls.selected, ['local-adsb:abc123', 'local-adsb:def456']); // Another layer's own contact on top is that layer's click. - harness.scene.pickResult = { id: 'abc123', primitive: {} }; - harness.scene.drillResult = [ - harness.scene.pickResult, - { id: first, primitive: {} }, - ]; + harness.scene.pickResult = { id: 'abc123' }; + harness.scene.drillResult = [harness.scene.pickResult, { id: first }]; harness.click(); assert.equal(calls.selected.length, 2, 'the Flights contact is not taken'); }); @@ -1214,7 +1148,7 @@ test('globe handoff retains the receiver, feed, trail fixes and selection', asyn }); await layer.update(); assert.equal(layer.getAnalystRecords()[0].icao24, 'abc123'); - layer.attachRenderer(fakeViewer().viewer); + layer.attachRenderer(null, { engine: fakeEngine() }); assert.deepEqual(receiver.modeCalls, ['adsb']); assert.deepEqual(feeds.calls, ['start']); assert.equal(closes, 0); diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js index 85377fbbb..24234fe56 100644 --- a/src/layers/localAdsb/models.js +++ b/src/layers/localAdsb/models.js @@ -29,8 +29,7 @@ const specKey = (spec) => `${spec.url}@${spec.scale}`; * are drawn by the layer's renderer (its `models`: the shared aircraft * renderer's model part) and read the camera through its `view`. * @param {object} options - * @param {object} options.renderer The layer's renderer (render/cesium.js, - * render/navara.js). + * @param {object} options.renderer The layer's display (render/display.js). * @param {object} options.color Model tint (a colour with red/green/blue/alpha). * @param {(url: string) => string} options.resolveAsset * @param {object|null} [options.groundSnap] `createGroundSnap()` instance. diff --git a/src/layers/localAdsb/render/cesium.js b/src/layers/localAdsb/render/cesium.js deleted file mode 100644 index 1fa81ffae..000000000 --- a/src/layers/localAdsb/render/cesium.js +++ /dev/null @@ -1,217 +0,0 @@ -// The Local ADS-B layer's renderer on Cesium: its -// CustomDataSource of marker entities (billboard with per-frame position, -// rotation and visibility callbacks, a 2,000 km display distance, the 978 MHz -// UAT ring as a point graphic), its model PrimitiveCollection (models load, -// pose and leave through the shared aircraft renderer's model part), the -// selected aircraft's trail and live head segment, and the camera reads. -// Nothing here decides anything: the layer's logic decides, this draws. -// -// The renderer (contract shared with render/navara.js): -// view camera, scene, horizon(), frustumTester(), -// surfaceOwner, requestRender(), on('preRender', fn) -// attach(), setVisible(bool), detach() -// markers.add({ id, image, scale, position(), rotation(), show() }) → -// handle (an Entity on Cesium: the context and readout annotations go -// on it), remove(handle), setImage(handle, image), setScale(handle, -// scale), hasRing(handle), setRing(handle, on, visible()) -// models the aircraft renderer's models (live, loadFleet, -// add, remove, destroy, setPose), or null without a -// primitive collection -// trails.create({ color, width }) → Trail | null, createHead({ id, -// positions(), color, width }), removeHead(head) -// engine, destroy() - -import * as Cesium from 'cesium'; -import { createAircraftCesiumRenderer } from '../../aircraft/render/cesium.js'; -import { MODEL_HEADING_OFFSET_DEG } from '../../flights/policy.js'; -import { - LAYER_ID, - LOCAL_ADSB_COLOR, - LOCAL_ADSB_UAT_RING_COLOR, -} from '../policy.js'; - -/** Markers (and the UAT ring) are not drawn past this camera distance (m). */ -export const LOCAL_ADSB_DISPLAY_DISTANCE_M = 2_000_000; - -/** - * Registry factory (src/app/layers/renderers.js): `(engine, options)`. - * @param {object|null} engine The Cesium globe engine (null in unit tests). - * @param {{ viewer: object, loadModel?: Function, createTrail?: Function }} options - * `loadModel`: the glTF loader (test seam); `createTrail`: the trail - * renderer (data/trailRenderer createTrail; no trail without it). - */ -export function createLocalAdsbCesiumRenderer( - engine, - { viewer, loadModel, createTrail = null } = {}, -) { - const color = Cesium.Color.fromCssColorString(LOCAL_ADSB_COLOR); - const uatRingColor = Cesium.Color.fromCssColorString( - LOCAL_ADSB_UAT_RING_COLOR, - ); - let dataSource = null; - let modelCollection = null; - let aircraft = null; - - const view = { - get camera() { - return viewer?.camera; - }, - get scene() { - return viewer?.scene; - }, - get surfaceOwner() { - return viewer; - }, - horizon: () => aircraft.view.horizon(), - frustumTester: () => aircraft.view.frustumTester(), - requestRender: () => viewer?.scene?.requestRender?.(), - on(event, listener) { - if (event !== 'preRender') return null; - return viewer?.scene?.preRender?.addEventListener - ? viewer.scene.preRender.addEventListener(listener) - : null; - }, - }; - - const uatRing = (visible) => - new Cesium.PointGraphics({ - pixelSize: 24, - color: Cesium.Color.TRANSPARENT, - outlineColor: uatRingColor, - outlineWidth: 1.5, - scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5), - show: new Cesium.CallbackProperty(() => visible(), false), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - distanceDisplayCondition: new Cesium.DistanceDisplayCondition( - 0, - LOCAL_ADSB_DISPLAY_DISTANCE_M, - ), - }); - - const markers = { - add(spec) { - return dataSource.entities.add({ - id: spec.id, - position: new Cesium.CallbackProperty(() => spec.position(), false), - billboard: { - image: spec.image, - width: 20, - height: 20, - scale: 1, - color, - sizeInMeters: false, - scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5), - alignedAxis: Cesium.Cartesian3.ZERO, - rotation: new Cesium.CallbackProperty(() => spec.rotation(), false), - show: new Cesium.CallbackProperty(() => spec.show(), false), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - distanceDisplayCondition: new Cesium.DistanceDisplayCondition( - 0, - LOCAL_ADSB_DISPLAY_DISTANCE_M, - ), - }, - }); - }, - remove(handle) { - dataSource?.entities.remove(handle); - }, - setImage(handle, image) { - if (handle?.billboard) handle.billboard.image = image; - }, - setScale(handle, scale) { - if (handle?.billboard) handle.billboard.scale = scale; - }, - hasRing: (handle) => Boolean(handle?.point), - setRing(handle, on, visible) { - handle.point = on ? uatRing(visible) : undefined; - }, - }; - - const trails = { - create: ({ color: css, width }) => - typeof createTrail === 'function' && viewer?.entities - ? createTrail(viewer, { color: css, width }) - : null, - // Live head: last heard fix → the extrapolated marker, every frame. - createHead({ id, positions, width }) { - return viewer.entities.add({ - // 'gev-trail' namespace: claimed by the trail pick owner so a click - // on the head segment never reads as empty space. - id, - polyline: { - positions: new Cesium.CallbackProperty(() => positions(), false), - width, - material: color.withAlpha(0.9), - depthFailMaterial: color.withAlpha(0.45), - arcType: Cesium.ArcType.GEODESIC, - }, - }); - }, - removeHead(head) { - if (head && viewer && !viewer.isDestroyed?.()) { - try { - viewer.entities.remove(head); - } catch { - /* torn down */ - } - } - }, - }; - - const renderer = { - kind: 'cesium', - get engine() { - return aircraft?.engine ?? engine; - }, - view, - markers, - trails, - /** The aircraft renderer's models, or null (no primitive collection). */ - models: null, - /** The model tint (the layer's magenta). */ - modelColor: color, - attach() { - dataSource = new Cesium.CustomDataSource(LAYER_ID); - dataSource.show = false; - viewer.dataSources.add(dataSource); - if (viewer.scene?.primitives) { - modelCollection = new Cesium.PrimitiveCollection(); - viewer.scene.primitives.add(modelCollection); - } - aircraft = createAircraftCesiumRenderer({ - viewer, - engine, - spriteLayer: null, - headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, - adopt: { modelCollection }, - ...(loadModel ? { loadModel } : {}), - }); - if (modelCollection) renderer.models = aircraft.models; - }, - setVisible(visible) { - if (dataSource) dataSource.show = visible; - }, - /** The data source, for the context slot's metadata. */ - get dataSource() { - return dataSource; - }, - // Removing the model collection destroys it and the models left in it. - detach() { - if ( - modelCollection && - !viewer.isDestroyed?.() && - !modelCollection.isDestroyed() - ) - viewer.scene.primitives.remove(modelCollection); - modelCollection = null; - if (viewer && dataSource) viewer.dataSources.remove(dataSource, true); - dataSource = null; - renderer.models = null; - }, - destroy() { - aircraft?.destroy(); - aircraft = null; - }, - }; - return renderer; -} diff --git a/src/layers/localAdsb/render/navara.js b/src/layers/localAdsb/render/display.js similarity index 67% rename from src/layers/localAdsb/render/navara.js rename to src/layers/localAdsb/render/display.js index 8330199cf..63356b84f 100644 --- a/src/layers/localAdsb/render/navara.js +++ b/src/layers/localAdsb/render/display.js @@ -1,16 +1,27 @@ -// The Local ADS-B layer's renderer on the Navara engine, -// with render/cesium.js's interface: the shared aircraft renderer -// (src/layers/aircraft/render/navara.js) draws the magenta markers (its -// fleet glyphs), the Flights models and the trail; the markers' per-frame -// position, rotation and visibility (Cesium's CallbackProperties and the -// 2,000 km display distance) are written each frame; the 978 MHz UAT ring -// is its own sprite set (Cesium's 24 px transparent point with a 1.5 px -// outline). Registered for 'navara' by the Navara mode after its import() -// (src/app/navaraMode.js). The camera never follows a local aircraft. +// The Local ADS-B layer's display, the same on every engine: the aircraft +// display (src/layers/aircraft/render/display.js) draws the magenta markers +// (its fleet glyphs), the Flights models and the trail; the markers' +// position, rotation and visibility are read each frame, within a 2,000 km +// display distance; the 978 MHz UAT ring is a point set (24 px, transparent, +// a 1.5 px outline). The camera never follows a local aircraft. +// +// The display: +// view camera, scene, horizon(), frustumTester(), +// surfaceOwner, requestRender(), on('preRender', fn) +// attach(), setVisible(bool), detach() +// markers.add({ id, image, position(), rotation(), show() }) → handle +// (the context and readout annotations go on it), remove(handle), +// setImage(handle, image), setScale(handle, scale), hasRing(handle), +// setRing(handle, on, visible()) +// models the aircraft display's models (live, loadFleet, +// add, remove, destroy, setPose) +// trails.create({ color, width }) → Trail, createHead({ id, positions(), +// color, width }), removeHead(head) +// engine, destroy() import * as geo from '../../../geo/index.js'; -import { createAircraftNavaraRenderer } from '../../aircraft/render/navara.js'; -import { createMoverBank } from '../../shared/render/navaraMoverBank.js'; +import { createAircraftDisplay } from '../../aircraft/render/display.js'; +import { createMoverBank } from '../../shared/render/moverBank.js'; import { MODEL_HEADING_OFFSET_DEG } from '../../flights/policy.js'; import { nearFarScale, rgbaFromCss } from '../../shared/color.js'; import { @@ -24,81 +35,63 @@ export const LOCAL_ADSB_DISPLAY_DISTANCE_M = 2_000_000; const MAGENTA = Object.freeze(rgbaFromCss(LOCAL_ADSB_COLOR)); const RING = rgbaFromCss(LOCAL_ADSB_UAT_RING_COLOR).rgba(); +const TRANSPARENT = [0, 0, 0, 0]; const SCALE_BY_DISTANCE = Object.freeze( nearFarScale(1000, 3.0, 8_000_000, 0.5), ); -// Cesium's point: 24 px plus a 1.5 px outline on each side, the outline -// drawn on the outer 1.5 px. const RING_PIXEL_SIZE = 24; const RING_OUTLINE_PX = 1.5; -const RING_TOTAL_PX = RING_PIXEL_SIZE + 2 * RING_OUTLINE_PX; +// Under the aircraft layers' sprites (data/spriteOrder). const MARKER_ORDER = -1; -/** The ring's atlas: a white ring on a 64 px canvas (tinted per instance). */ -function drawUatRingAtlas(size = 64) { - const canvas = globalThis.document.createElement('canvas'); - canvas.width = size; - canvas.height = size; - const context = canvas.getContext('2d'); - const scale = size / RING_TOTAL_PX; - const outer = (RING_TOTAL_PX / 2) * scale; - const band = RING_OUTLINE_PX * scale; - context.strokeStyle = '#ffffff'; - context.lineWidth = band; - context.beginPath(); - context.arc(size / 2, size / 2, outer - band / 2, 0, Math.PI * 2); - context.stroke(); - return { canvas, cells: { ring: [0, 0, 1, 1] } }; -} - /** * Registry factory (src/app/layers/renderers.js): `(engine, options)`. * @param {import('../../../globe/contract.js').GlobeEngine} engine - * @param {{ drawAtlas?: Function, drawRing?: Function }} [options] Test seams. + * @param {{ drawAtlas?: Function }} [options] Test seam: the glyph atlas. */ -export function createLocalAdsbNavaraRenderer( - engine, - { drawAtlas, drawRing = drawUatRingAtlas } = {}, -) { - const aircraft = createAircraftNavaraRenderer({ - engine, +export function createLocalAdsbDisplay(engine, { drawAtlas } = {}) { + const aircraft = createAircraftDisplay(engine, { layerId: LAYER_ID, - // Under every sprite collection, as Cesium draws the layer's entities - // (data/spriteOrder raises the collections over the data sources). order: MARKER_ORDER, headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, ...(drawAtlas ? { drawAtlas } : {}), }); const view = aircraft.view; + // Hidden until the layer shows it. + aircraft.fleet.setVisible(false); const handles = new Set(); let visible = false; let ringBank = null; + let ringDirty = false; let removeFrame = null; const scratch = geo.cartesian3.create(); const ringWrite = (handle, set, index) => { set.set(index, { position: handle.sprite.position, - color: RING, - cell: 'ring', + color: TRANSPARENT, show: handle.ringShown, pickId: handle.id, }); }; + const commitRings = () => { + ringDirty = false; + ringBank?.commit(); + }; const ringBankFor = () => { if (ringBank) return ringBank; - const atlas = drawRing(); ringBank = createMoverBank(engine, { options: (capacity, generation) => ({ id: `${LAYER_ID}-rings-${generation}`, - kind: 'sprite', + kind: 'point', capacity, order: MARKER_ORDER + 0.25, depthTest: 'always', pickable: true, - sprite: { - atlas, - sizePx: RING_TOTAL_PX, + point: { + sizePx: RING_PIXEL_SIZE, + outlinePx: RING_OUTLINE_PX, + outlineColor: RING, scaleByDistance: [ SCALE_BY_DISTANCE.near, SCALE_BY_DISTANCE.nearValue, @@ -108,13 +101,19 @@ export function createLocalAdsbNavaraRenderer( }, }), write: ringWrite, - onCommit: (bank) => queueMicrotask(() => bank.commit()), + // Committed at the end of the frame pass, or in a microtask. + onCommit: () => { + if (ringDirty) return; + ringDirty = true; + queueMicrotask(commitRings); + }, }); ringBank.setVisible(visible); return ringBank; }; - // Each frame: Cesium's per-frame callbacks and its display distance. + // Each frame: the markers' position, rotation and visibility, and the + // display distance. const frame = () => { const camera = view.camera.positionWC; const limitSq = LOCAL_ADSB_DISPLAY_DISTANCE_M ** 2; @@ -138,7 +137,7 @@ export function createLocalAdsbNavaraRenderer( }); } } - ringBank?.commit(); + commitRings(); }; const markers = { @@ -193,16 +192,17 @@ export function createLocalAdsbNavaraRenderer( }; return { - kind: 'navara', engine, view, markers, models: aircraft.models, trails: aircraft.trails, + /** The model tint (the layer's magenta). */ modelColor: MAGENTA, dataSource: null, whenReady: () => aircraft.whenReady(), attach() { + aircraft.fleet.attach(); removeFrame ??= view.on('preRender', frame); }, setVisible(next) { diff --git a/src/layers/localAdsb/renderer.test.mjs b/src/layers/localAdsb/renderer.test.mjs index 64dcffa2e..ce75bbc63 100644 --- a/src/layers/localAdsb/renderer.test.mjs +++ b/src/layers/localAdsb/renderer.test.mjs @@ -1,14 +1,14 @@ -// The Local ADS-B logic on its Navara renderer over a -// fake engine: magenta markers in the shared aircraft renderer's glyph sets, -// the UAT ring as its own set, a click through the engine's selection +// The Local ADS-B logic on its display over a +// fake engine: magenta markers in the aircraft display's glyph sets, +// the UAT ring as its own point set, a click through the engine's selection // dispatcher selects (the camera never follows), empty map releases, and the // layer leaves no mover set behind. import assert from 'node:assert/strict'; import test from 'node:test'; import { createLocalAdsbLayer } from './index.js'; -import { createLocalAdsbNavaraRenderer } from './render/navara.js'; -import { aircraftAtlasLayout } from '../aircraft/render/navara.js'; +import { createLocalAdsbDisplay } from './render/display.js'; +import { aircraftAtlasLayout } from '../aircraft/render/display.js'; import * as selection from '../../data/selectionDispatcher.js'; import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import * as geo from '../../geo/index.js'; @@ -49,7 +49,7 @@ const record = (icao, lat, band = '1090') => ({ source: 'feed', }); -test('local aircraft draw, ring and select on the Navara renderer; the camera never follows', async () => { +test('local aircraft draw, ring and select on the display; the camera never follows', async () => { const engine = createFakeEngine(); engine.camera.setView({ destination: geo.cartesian3.fromDegrees(0, 0, 60_000), @@ -76,15 +76,11 @@ test('local aircraft draw, ring and select on the Navara renderer; the camera ne selection, renderer: { create: ({ engine: given }) => - (renderer = createLocalAdsbNavaraRenderer(given, { + (renderer = createLocalAdsbDisplay(given, { drawAtlas: async () => ({ canvas: { width: layout.width, height: layout.height }, cells: layout.cells, }), - drawRing: () => ({ - canvas: { width: 64, height: 64 }, - cells: { ring: [0, 0, 1, 1] }, - }), })), }, }, @@ -115,6 +111,19 @@ test('local aircraft draw, ring and select on the Navara renderer; the camera ne const rings = sets().filter(({ id }) => id.startsWith('local-adsb-rings')); assert.equal(rings.length, 1); assert.equal(rings[0].count, 1, 'a ring for the UAT-only aircraft'); + // The ring: a transparent 24 px point with a 1.5 px outline, over the + // markers, drawn through the scene. + const ring = engine.fake.moverSet(rings[0].id); + assert.equal(ring.options.kind, 'point'); + assert.equal(ring.options.point.sizePx, 24); + assert.equal(ring.options.point.outlinePx, 1.5); + assert.equal(ring.options.depthTest, 'always'); + assert.deepEqual([...ring.store.colors.slice(0, 4)], [0, 0, 0, 0]); + assert.equal(ring.store.pickIds[0], 'local-adsb:def456'); + assert.equal( + engine.fake.moverSet(sprites[0].id).options.depthTest, + 'always', + ); assert.equal(layer.getAllPositions().length, 2); // A click selects through the dispatcher; the camera does not follow. diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs index efb0b4aca..cbaf2681a 100644 --- a/src/layers/military/ownership.test.mjs +++ b/src/layers/military/ownership.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { createMilitaryFlightLayer } from './index.js'; import { createFlightState } from './state.js'; import { createTracking } from './tracking.js'; +import { aircraftViewerRenderer } from '../../testSupport/aircraftViewerRenderer.mjs'; function services() { const names = [ @@ -31,17 +32,13 @@ function services() { // The application's own scene services, as src/app/layers/militaryFlights.js // composes them; only the surface samplers and registry are stubbed. async function sceneServices() { - const { createMilitaryCesiumRenderer } = await import('./render/cesium.js'); // The application's own scene services, as src/app/layers/militaryFlights.js // composes them; only the surface samplers and registry are stubbed. return { picking: await import('../../data/pickRegistry.js'), selection: await import('../../data/selectionDispatcher.js'), - // The layer's Cesium renderer, as the app creates it without an engine. - renderer: { - create: ({ viewer, adopt }) => - createMilitaryCesiumRenderer(null, { viewer, adopt }), - }, + // Seeded through the testing seam with the test's own renderer. + renderer: { create: () => null }, aircraftPresentation: await import('../../data/tr3bRegistry.js'), camera: await import('../../data/trackedCamera.js'), labels: await import('../../data/detectionDraw.js'), @@ -186,6 +183,7 @@ async function firstPlacement(appClockLagMs) { }); const added = []; layer.testing._setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: 'seed00', entity: null, meta: { rawLat: 0, rawLon: 0, onGround: false }, @@ -289,6 +287,7 @@ test('a vanished military contact fades while position-less rows keep theirs', a }); const viewer = { camera: { positionCartographic: null }, scene: {} }; layer.testing._setTrackedMilitaryRefreshStateForTest({ + renderer: aircraftViewerRenderer, icao24: 'seed00', entity: null, meta: { rawLat: 0, rawLon: 0, onGround: false }, diff --git a/src/layers/military/render/cesium.js b/src/layers/military/render/cesium.js deleted file mode 100644 index ebd3c4042..000000000 --- a/src/layers/military/render/cesium.js +++ /dev/null @@ -1,24 +0,0 @@ -// The military flights layer's renderer on Cesium: the shared aircraft -// renderer (src/layers/aircraft/render/cesium.js) with the military's -// sprite-order slot and jet.glb's nose offset (the per-class specs pass -// their own offset to setPose). The military's presentation choices (amber -// #FFB800 fleet, #FFD166 tracked, the IR boost as an unlit white model) are -// the layer's logic and reach the renderer as plain data. - -import { createAircraftCesiumRenderer } from '../../aircraft/render/cesium.js'; -import { MODEL_HEADING_OFFSET_DEG } from '../policy.js'; - -/** - * Registry factory (src/app/layers/renderers.js): `(engine, options)`. - * @param {object|null} engine The Cesium globe engine (null in unit tests). - * @param {{ viewer: object, adopt?: object }} options - */ -export function createMilitaryCesiumRenderer(engine, { viewer, adopt } = {}) { - return createAircraftCesiumRenderer({ - viewer, - engine, - spriteLayer: 'military', - headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, - adopt, - }); -} diff --git a/src/layers/military/render/display.js b/src/layers/military/render/display.js new file mode 100644 index 000000000..350c312b1 --- /dev/null +++ b/src/layers/military/render/display.js @@ -0,0 +1,15 @@ +// The military flights layer's display, the same on every engine: the +// aircraft display (src/layers/aircraft/render/display.js) in the military's +// sprite slot with jet.glb's nose offset (the per-class specs pass their own +// offset to setPose). The military's presentation choices (amber #FFB800 +// fleet, #FFD166 tracked, the IR boost as an unlit white model) are the +// layer's logic and reach the display as plain data. + +import { aircraftLayerDisplay } from '../../aircraft/render/display.js'; +import { MODEL_HEADING_OFFSET_DEG } from '../policy.js'; + +/** Registry factory (src/app/layers/renderers.js): `(engine, options)`. */ +export const createMilitaryDisplay = aircraftLayerDisplay( + 'military', + MODEL_HEADING_OFFSET_DEG, +); diff --git a/src/layers/military/render/navara.js b/src/layers/military/render/navara.js deleted file mode 100644 index 1982d6c74..000000000 --- a/src/layers/military/render/navara.js +++ /dev/null @@ -1,11 +0,0 @@ -// The military flights layer's renderer on the Navara engine: the shared -// aircraft renderer with jet.glb's nose offset. Registered for 'navara' by -// the Navara mode after its import() (src/app/navaraMode.js). - -import { aircraftLayerNavaraRenderer } from '../../aircraft/render/navara.js'; -import { MODEL_HEADING_OFFSET_DEG } from '../policy.js'; - -export const createMilitaryNavaraRenderer = aircraftLayerNavaraRenderer( - 'military', - MODEL_HEADING_OFFSET_DEG, -); diff --git a/src/layers/military/renderer.test.mjs b/src/layers/military/renderer.test.mjs index 08f3416b8..28652384f 100644 --- a/src/layers/military/renderer.test.mjs +++ b/src/layers/military/renderer.test.mjs @@ -138,9 +138,9 @@ test('the military logic draws, selects and follows only through its renderer', assert.equal(keyListeners.size, 0, 'the dispatcher left the keyboard'); }); -test('the military logic draws and follows on the Navara renderer; the follow passes between flights and military', async () => { - const { createAircraftNavaraRenderer, aircraftAtlasLayout } = - await import('../aircraft/render/navara.js'); +test('the military logic draws and follows on the aircraft display; the follow passes between flights and military', async () => { + const { createAircraftDisplay, aircraftAtlasLayout } = + await import('../aircraft/render/display.js'); const { createApplicationFlights } = await import('../../app/layers/flights.js'); const { openSkySnapshot } = await import('../../sources/live/aircraft.js'); @@ -153,8 +153,7 @@ test('the military logic draws and follows on the Navara renderer; the follow pa const renderers = {}; const navara = (layerId, order) => ({ create: ({ engine: given }) => - (renderers[layerId] = createAircraftNavaraRenderer({ - engine: given, + (renderers[layerId] = createAircraftDisplay(given, { layerId, order, headingOffsetDeg: 180, diff --git a/src/layers/military/state.js b/src/layers/military/state.js index 274b6e4b0..9dac0abd0 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -33,9 +33,9 @@ export function createFlightState({ source, services }) { flightState._scratchGroundPos = geo.cartesian3.create(); /** @type {import('../aircraft/render/contract.js').AircraftRenderer|null} - * The engine's aircraft renderer for this lifecycle (render/cesium.js on - * Cesium): the fleet sprites, the models and their collection, the - * tracked visual, the trail, the follow camera and the view reads. */ + * The engine's aircraft renderer for this lifecycle (render/display.js, + * on every engine): the fleet sprites, the models, the tracked visual, + * the trail, the follow camera and the view reads. */ flightState._renderer = null; diff --git a/src/layers/military/testing.js b/src/layers/military/testing.js index c91858322..7d242b721 100644 --- a/src/layers/military/testing.js +++ b/src/layers/military/testing.js @@ -1,4 +1,8 @@ -import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js'; +import { + AMBER_TRANSPARENT, + MODEL_HEADING_OFFSET_DEG, + TRACKED_ICON_COLOR, +} from './policy.js'; export function createTesting({ flightState, @@ -18,6 +22,8 @@ export function createTesting({ * @param {object} state.billboard * @param {object} state.billboardCollection * @param {object} state.viewer + * @param {(options: object) => object} state.renderer The test's renderer + * over the viewer and collections (testSupport/aircraftViewerRenderer). * @param {Array} [state.history=[]] * @param {boolean} [state.tracked=true] * @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed @@ -35,12 +41,15 @@ export function createTesting({ tracked = true, models = [], modelCollection = null, + renderer: createRenderer, }) { flightState.enabled = billboardCollection?.show !== false; - // The production renderer over the test's viewer and (fake) collections. - flightState._renderer = services.renderer.create({ + // The test's renderer over its viewer and (fake) collections. + flightState._renderer = createRenderer({ viewer, - adopt: { billboardCollection, modelCollection }, + billboardCollection, + modelCollection, + headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, }); flightState._billboards = new Map([[icao24, billboard]]); flightState._cullPositions.clear(); diff --git a/src/layers/shared/render/moverBank.js b/src/layers/shared/render/moverBank.js new file mode 100644 index 000000000..275683cb0 --- /dev/null +++ b/src/layers/shared/render/moverBank.js @@ -0,0 +1,128 @@ +// A growing bank of engine.movers sprites, points or models that layer +// renderers share (on any engine, through the contract): handles hold their +// index into one mover set, which is rebuilt at double the capacity when it +// fills. + +const FIRST_CAPACITY = 1024; + +// Reused set() arguments (set() copies them; no object per write). +const fieldsNoPick = { + position: undefined, + orientation: undefined, + color: undefined, + rotation: undefined, + scale: undefined, + cell: undefined, + show: undefined, +}; +/** Writes `fields` of the mover at `index` (set() copies them). */ +export function writeFields(set, index, fields) { + const scratch = fieldsNoPick; + for (const key in fields) scratch[key] = fields[key]; + set.set(index, scratch); + for (const key in fields) scratch[key] = undefined; +} + +/** + * A growing bank of one kind of mover: handles hold an index; removal moves + * the last handle into the hole. `options(capacity, generation)` gives the + * createSet options; `write(handle, set, index)` writes a handle whole. + */ +export function createMoverBank(engine, { options, write, onCommit }) { + const handles = []; + let set = null; + let generation = 0; + let capacity = 0; + let visible = true; + let dirty = false; + const build = (wanted) => { + const next = engine.movers.createSet(options(wanted, ++generation)); + set?.destroy(); + set = next; + capacity = wanted; + handles.forEach((handle, index) => write(handle, set, index)); + set.setCount(visible ? handles.length : 0); + }; + const bank = { + handles, + get set() { + return set; + }, + ready: () => Boolean(set?.ready()), + add(handle) { + if (handle.gevBank === bank) return; + handle.gevBank?.remove(handle); + handle.gevBank = bank; + handle.gevIndex = handles.length; + handles.push(handle); + if (!set || handles.length > capacity) + build(Math.max(FIRST_CAPACITY, capacity * 2, handles.length)); + else { + write(handle, set, handle.gevIndex); + if (visible) set.setCount(handles.length); + } + bank.touch(); + }, + remove(handle) { + if (handle.gevBank !== bank) return; + const index = handle.gevIndex; + const last = handles.pop(); + if (last !== handle) { + handles[index] = last; + last.gevIndex = index; + write(last, set, index); + } + handle.gevBank = null; + handle.gevIndex = -1; + set?.setCount(visible ? handles.length : 0); + bank.touch(); + }, + /** Writes `fields` of one handle (when it is in this bank). */ + update(handle, fields) { + if (handle.gevBank !== bank || !set) return; + writeFields(set, handle.gevIndex, fields); + bank.touch(); + }, + setVisible(next) { + visible = next; + set?.setCount(visible ? handles.length : 0); + bank.touch(); + }, + visible: () => visible, + touch() { + if (!dirty) { + dirty = true; + onCommit(bank); + } + }, + commit() { + if (!dirty) return; + dirty = false; + set?.commit(); + }, + destroy() { + for (const handle of handles) { + handle.gevBank = null; + handle.gevIndex = -1; + } + handles.length = 0; + set?.destroy(); + set = null; + }, + }; + return bank; +} + +/** Plain colour copy (the stored value a handle's colour getter returns). */ +export class StoredColor { + constructor(color) { + this.red = color?.red ?? 1; + this.green = color?.green ?? 1; + this.blue = color?.blue ?? 1; + this.alpha = color?.alpha ?? 1; + } + + withAlpha(alpha) { + return new StoredColor({ ...this, alpha }); + } +} diff --git a/src/layers/shared/render/navaraMoverBank.js b/src/layers/shared/render/navaraMoverBank.js index adc9c3f33..023b34c0d 100644 --- a/src/layers/shared/render/navaraMoverBank.js +++ b/src/layers/shared/render/navaraMoverBank.js @@ -1,127 +1,2 @@ -// A growing bank of engine.movers sprites or models that Navara renderers -// share: handles hold their index into one mover set, which is rebuilt at -// double the capacity when it fills. - -const FIRST_CAPACITY = 1024; - -// Reused set() arguments (set() copies them; no object per write). -const fieldsNoPick = { - position: undefined, - orientation: undefined, - color: undefined, - rotation: undefined, - scale: undefined, - cell: undefined, - show: undefined, -}; -/** Writes `fields` of the mover at `index` (set() copies them). */ -export function writeFields(set, index, fields) { - const scratch = fieldsNoPick; - for (const key in fields) scratch[key] = fields[key]; - set.set(index, scratch); - for (const key in fields) scratch[key] = undefined; -} - -/** - * A growing bank of one kind of mover: handles hold an index; removal moves - * the last handle into the hole. `options(capacity, generation)` gives the - * createSet options; `write(handle, set, index)` writes a handle whole. - */ -export function createMoverBank(engine, { options, write, onCommit }) { - const handles = []; - let set = null; - let generation = 0; - let capacity = 0; - let visible = true; - let dirty = false; - const build = (wanted) => { - const next = engine.movers.createSet(options(wanted, ++generation)); - set?.destroy(); - set = next; - capacity = wanted; - handles.forEach((handle, index) => write(handle, set, index)); - set.setCount(visible ? handles.length : 0); - }; - const bank = { - handles, - get set() { - return set; - }, - ready: () => Boolean(set?.ready()), - add(handle) { - if (handle.gevBank === bank) return; - handle.gevBank?.remove(handle); - handle.gevBank = bank; - handle.gevIndex = handles.length; - handles.push(handle); - if (!set || handles.length > capacity) - build(Math.max(FIRST_CAPACITY, capacity * 2, handles.length)); - else { - write(handle, set, handle.gevIndex); - if (visible) set.setCount(handles.length); - } - bank.touch(); - }, - remove(handle) { - if (handle.gevBank !== bank) return; - const index = handle.gevIndex; - const last = handles.pop(); - if (last !== handle) { - handles[index] = last; - last.gevIndex = index; - write(last, set, index); - } - handle.gevBank = null; - handle.gevIndex = -1; - set?.setCount(visible ? handles.length : 0); - bank.touch(); - }, - /** Writes `fields` of one handle (when it is in this bank). */ - update(handle, fields) { - if (handle.gevBank !== bank || !set) return; - writeFields(set, handle.gevIndex, fields); - bank.touch(); - }, - setVisible(next) { - visible = next; - set?.setCount(visible ? handles.length : 0); - bank.touch(); - }, - visible: () => visible, - touch() { - if (!dirty) { - dirty = true; - onCommit(bank); - } - }, - commit() { - if (!dirty) return; - dirty = false; - set?.commit(); - }, - destroy() { - for (const handle of handles) { - handle.gevBank = null; - handle.gevIndex = -1; - } - handles.length = 0; - set?.destroy(); - set = null; - }, - }; - return bank; -} - -/** Plain colour copy (the stored value a handle's colour getter returns). */ -export class StoredColor { - constructor(color) { - this.red = color?.red ?? 1; - this.green = color?.green ?? 1; - this.blue = color?.blue ?? 1; - this.alpha = color?.alpha ?? 1; - } - - withAlpha(alpha) { - return new StoredColor({ ...this, alpha }); - } -} +// The mover bank under the name the Navara renderers import it by. +export { createMoverBank, StoredColor, writeFields } from './moverBank.js'; diff --git a/src/layers/transit/render/cesium.js b/src/layers/transit/render/cesium.js index 2cd3ff05d..17805cb92 100644 --- a/src/layers/transit/render/cesium.js +++ b/src/layers/transit/render/cesium.js @@ -12,7 +12,7 @@ import { createContactTrailRenderer } from '../../../data/render/cesiumContactTr import { CAMERA_PERCENTAGE_CHANGED } from '../policy.js'; /** Native resource owner; feed/playback/selection policy stays in the layer. */ -export function createTransitCesiumRenderer({ state, services }) { +export function createTransitCesiumRenderer({ state, services, engine }) { let viewer = null; const banks = []; const removers = new Set(); @@ -99,13 +99,13 @@ export function createTransitCesiumRenderer({ state, services }) { }, claimCamera() { claimCameraSensitivity( - viewer?.camera, + engine ?? viewer?.camera, 'transit', CAMERA_PERCENTAGE_CHANGED, ); }, releaseCamera() { - releaseCameraSensitivity(viewer?.camera, 'transit'); + releaseCameraSensitivity(engine ?? viewer?.camera, 'transit'); }, onCameraChanged: (fn) => own(viewer.camera.changed.addEventListener(fn)), onPreRender: (fn) => own(viewer.scene.preRender.addEventListener(fn)), diff --git a/src/layers/wildlifeSightings/acquisition.test.mjs b/src/layers/wildlifeSightings/acquisition.test.mjs index b4ac47813..4447be55b 100644 --- a/src/layers/wildlifeSightings/acquisition.test.mjs +++ b/src/layers/wildlifeSightings/acquisition.test.mjs @@ -8,6 +8,7 @@ import { createWildlifeSightingsSource } from './source.js'; import { wildlifeGridCellId } from './gridViewport.js'; import { LayerLifecycle } from '../../data/lifecycle.js'; import { layerFeedState } from '../../data/feedState.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; const NOW = Date.UTC(2026, 10, 4, 18); const rectangle = { south: 10, west: 10, north: 12, east: 12 }; @@ -20,31 +21,26 @@ function harness(source = { getSnapshot: async () => [] }) { let notifications = 0; let managerRequests = 0; const timers = new Map(); - const cameraListeners = new Set(); const document = { hidden: false }; + // The camera sees `bounds` (degrees) from an exact `camera.height`. + const engine = createFakeEngine({ width: 1400, height: 900 }); + const camera = { height: 30_000 }; + engine.camera.height = () => camera.height; + engine.fake.script( + 'viewRectangle', + () => + bounds && + Object.fromEntries( + Object.entries(bounds).map(([key, value]) => [ + key, + (value * Math.PI) / 180, + ]), + ), + ); const state = { - viewer: { - camera: { - moveEnd: { - addEventListener(fn) { - cameraListeners.add(fn); - return () => cameraListeners.delete(fn); - }, - }, - positionCartographic: { height: 30_000 }, - computeViewRectangle: () => - bounds && - Object.fromEntries( - Object.entries(bounds).map(([key, value]) => [ - key, - (value * Math.PI) / 180, - ]), - ), - }, - }, + engine, enabled: true, destroyed: false, - points: {}, rows: new Map(), renderedRows: [], rowById: new Map(), @@ -118,6 +114,8 @@ function harness(source = { getSnapshot: async () => [] }) { }); return { state, + engine, + camera, layer, parts, timers, @@ -129,9 +127,7 @@ function harness(source = { getSnapshot: async () => [] }) { }, notifications: () => notifications, managerRequests: () => managerRequests, - moveEnd: () => { - for (const listener of cameraListeners) listener(); - }, + moveEnd: () => engine.fake.emit('camera.moveEnd'), async advance(ms) { time += ms; for (const [id, timer] of [...timers]) { @@ -347,8 +343,8 @@ test('SF WHALES failure keeps chosen error chip, drawn rows and manager DEGRADED }, }); h.bounds({ south: 37.2, west: -123, north: 38.2, east: -122 }); - h.state.viewer.camera.positionCartographic.height = 250_000; - const manager = new LayerLifecycle(h.state.viewer); + h.camera.height = 250_000; + const manager = new LayerLifecycle(null, { engine: h.engine }); manager.register({ ...h.layer, init: () => true, updateInterval: 0 }); h.layer.attachDataManager(manager); try { @@ -491,7 +487,7 @@ test('a selected observation survives omitted samples, zoom, changed filters and await h.layer.update(); assert.equal(h.state.rows.get(sighting.stableId), sighting); assert.equal(h.state.selectedId, sighting.stableId); - h.state.viewer.camera.positionCartographic.height = 3_500_000; + h.camera.height = 3_500_000; h.bounds({ south: 40, west: 40, north: 42, east: 42 }); await h.layer.update(); assert.equal(h.state.query.scope, 'global'); @@ -554,7 +550,7 @@ test('all heights and the sampled fallback use the same sightings-in-view count h.state.gridTotal = 1000; h.state.sightingCount = 870; for (const height of [30_000, 1_500_000]) { - h.state.viewer.camera.positionCartographic.height = height; + h.camera.height = height; assert.equal(h.layer.getStats().count, 870); assert.equal(h.layer.getStats().countLabel, '870 sightings in view'); } @@ -676,8 +672,8 @@ test('Europe density tiles publish before a slow observation page and survive it }, }); h.bounds({ south: 44, west: -8, north: 55, east: 24 }); - h.state.viewer.scene = { canvas: { clientWidth: 1280, clientHeight: 800 } }; - h.state.viewer.camera.positionCartographic.height = 1_500_000; + h.engine.fake.setSize(1280, 800); + h.camera.height = 1_500_000; const updating = h.layer.update(); assert.equal(tileOptions.tiles.length, 3); const first = { id: 'grid:first', lat: 48, lon: 8, cellCount: 4 }; @@ -800,12 +796,11 @@ test('an empty tile retires its old zoom cells while uncovered density survives }, }); h.bounds({ south: 44, west: -8, north: 55, east: 24 }); - h.state.viewer.scene = { canvas: { clientWidth: 160, clientHeight: 100 } }; - h.state.viewer.camera.positionCartographic.height = 1_500_000; + h.engine.fake.setSize(160, 100); + h.camera.height = 1_500_000; await h.layer.update(); first = false; - h.state.viewer.scene.canvas.clientWidth = 1280; - h.state.viewer.scene.canvas.clientHeight = 800; + h.engine.fake.setSize(1280, 800); const updating = h.layer.update(); const zoom = tileOptions.tiles[0].z; assert.ok(zoom > oldZoom); diff --git a/src/layers/wildlifeSightings/billboardModel.js b/src/layers/wildlifeSightings/billboardModel.js index 6b5b1093a..f05ec70fc 100644 --- a/src/layers/wildlifeSightings/billboardModel.js +++ b/src/layers/wildlifeSightings/billboardModel.js @@ -1,7 +1,7 @@ import { drainWildlifeWork, WILDLIFE_WORK_UNIT } from './performance.js'; import { wildlifeIconicGroup } from './model.js'; -/** Incremental billboard reconciliation, independent of Cesium and the DOM. +/** Incremental badge reconciliation, independent of the engine and the DOM. * A refresh changes only added/removed contacts and properties that differ. * Position sampling and appearance construction run only for changed rows. */ export function createWildlifeBillboardModel({ @@ -58,9 +58,8 @@ export function createWildlifeBillboardModel({ writes += 1; } for (const key of Object.keys(appearance)) { - // Cesium clones Color and NearFarScalar values on assignment. Our - // last descriptor, rather than getter reference identity, records - // whether the appearance has actually changed. + // The last descriptor, rather than getter reference identity, + // records whether the appearance has actually changed. if (old?.appearance?.[key] === appearance[key]) continue; point[key] = appearance[key]; writes += 1; @@ -95,20 +94,5 @@ export function createWildlifeBillboardModel({ previous.clear(); } - // Font readiness replaces just the cached texture. Never resample anchors, - // clear the collection or allocate replacement billboards for this swap. - function refreshImages(imageForGroup) { - let writes = 0; - for (const [id, old] of previous) { - const point = pointById.get(id); - const image = imageForGroup(old.group); - if (!point || old.appearance.image === image) continue; - point.image = image; - old.appearance = { ...old.appearance, image }; - writes += 1; - } - return writes; - } - - return { update, updateWork, clear, refreshImages }; + return { update, updateWork, clear }; } diff --git a/src/layers/wildlifeSightings/cards.test.mjs b/src/layers/wildlifeSightings/cards.test.mjs index a6a27c977..dcd9e1aa4 100644 --- a/src/layers/wildlifeSightings/cards.test.mjs +++ b/src/layers/wildlifeSightings/cards.test.mjs @@ -21,6 +21,7 @@ import { } from './policy.js'; import { formatAge } from './photoText.js'; import { createSelection } from './selection.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { setLocale } from '../../i18n/index.js'; import { altitudeFade, @@ -439,6 +440,11 @@ test('compact ages follow shared translated minute, hour and day forms', () => { }); function ambientFixture(rows, screens) { + const engine = createFakeEngine({ width: 1200, height: 900 }); + // Positions here are screen points already; heights are exact. + engine.fake.script('worldToWindow', (position) => position); + const camera = { height: 30_000 }; + engine.camera.height = () => camera.height; const state = { enabled: true, renderedRows: rows, @@ -451,10 +457,7 @@ function ambientFixture(rows, screens) { ), pointById: new Map(), rowById: new Map(rows.map((row) => [row.stableId, row])), - viewer: { - scene: { canvas: { clientWidth: 1200, clientHeight: 900 } }, - camera: { positionCartographic: { height: 30_000 } }, - }, + engine, }; const photos = []; const painted = []; @@ -464,7 +467,6 @@ function ambientFixture(rows, screens) { state, services: { now: () => now, - project: (_scene, position) => position, overlayHost: { setEntries(id, _entries, options) { entries.set(id, _entries); @@ -483,6 +485,7 @@ function ambientFixture(rows, screens) { }); return { state, + camera, selection, photos, painted, @@ -606,7 +609,7 @@ test('pans retain every on-screen photo and refreshes only fill free slots', () test('ambient show/hide thresholds have hysteresis without small-zoom toggles', () => { const a = row(); const f = ambientFixture([a], new Map([[a.stableId, { x: 150, y: 150 }]])); - const height = f.state.viewer.camera.positionCartographic; + const height = f.camera; height.height = WILDLIFE_CARD_SHOW_MAX_METERS - 1; f.selection.refreshAmbient(); assert.equal(f.state.ambientRows.length, 1); diff --git a/src/layers/wildlifeSightings/controls.js b/src/layers/wildlifeSightings/controls.js index ced04364f..6126261d7 100644 --- a/src/layers/wildlifeSightings/controls.js +++ b/src/layers/wildlifeSightings/controls.js @@ -230,8 +230,7 @@ export function createControls({ state, services, parts }) { if ( !state.error && !state.query && - state.viewer?.camera?.positionCartographic?.height > - WILDLIFE_GRID_HEIGHT_METERS + state.engine?.camera.height() > WILDLIFE_GRID_HEIGHT_METERS ) { // i18n-ignore: feed-state protocol value stats.status = 'zoom-in'; diff --git a/src/layers/wildlifeSightings/density.js b/src/layers/wildlifeSightings/density.js index 503b8d86a..0bfb8e714 100644 --- a/src/layers/wildlifeSightings/density.js +++ b/src/layers/wildlifeSightings/density.js @@ -1,4 +1,11 @@ +import * as geo from '../../geo/index.js'; import { wildlifeGridCellBounds } from './gridViewport.js'; +import { + createWildlifeSpriteBank, + wildlifeCameraView, + wildlifeMetresToPixels, + wildlifeNearFarValue, +} from './display.js'; import { createWildlifeWorkQueue, drainWildlifeWork, @@ -14,6 +21,10 @@ const METERS_PER_DEGREE = (2 * Math.PI * 6378137) / 360; const FADE_STEPS = 3; const FADE_STEP_MS = 60; const SPRITE_SIZE = 128; +const WGS84_MINIMUM_RADIUS = geo.ellipsoid.WGS84.minimumRadius; +// Under every contact sprite set (src/data/spriteOrder.js orders start at 0). +export const WILDLIFE_DENSITY_ORDER = -1; +export const WILDLIFE_DENSITY_SET_ID = 'wildlife-sightings-density'; // Fade coarse splats before they read as haze across the whole Earth disc. const ALTITUDE_FADE_START = 3_000_000; const ALTITUDE_FADE_END = 8_000_000; @@ -106,7 +117,7 @@ function intersects(bounds, boxes, padding = 0) { } // Fit the entire kernel inside the globe's tangent cone, plus 5% of its radius. -// A visible centre alone lets a depth-free billboard bleed across the limb. +// A visible centre alone lets a depth-free sprite bleed across the limb. // The inscribed sphere is conservative for WGS84 and needs no projections. function horizonTest(cameraPosition, radius) { const { x, y, z } = cameraPosition || {}; @@ -130,16 +141,33 @@ function horizonTest(cameraPosition, radius) { }; } -/** Own noninteractive, world-sized glow splats, arrival fades and style changes. */ /** A world-sized kernel fills the screen once the camera is inside it, e.g. - * while flying in before the closer view's tiles arrive. Cesium fades it on the - * GPU: gone within half its width, full beyond twice its width. */ + * while flying in before the closer view's tiles arrive. Its alpha fades with + * the camera distance: gone within half its width, full beyond twice its + * width (Cesium's NearFarScalar interpolation). */ export const WILDLIFE_DENSITY_NEAR_FADE = Object.freeze({ near: 0.5, far: 2 }); +const DENSITY_ATLAS = Object.freeze({ + url: DENSITY_IMAGE, + cells: Object.freeze({ glow: Object.freeze([0, 0, 1, 1]) }), +}); + +/** The near fade of a kernel `width` metres wide, as [nearM, 0, farM, 1]. */ +export function wildlifeDensityNearFade(width) { + return [ + width * WILDLIFE_DENSITY_NEAR_FADE.near, + 0, + width * WILDLIFE_DENSITY_NEAR_FADE.far, + 1, + ]; +} + +/** Own noninteractive, world-sized glow splats, arrival fades and style + * changes. Splats are sprites sized in metres: their pixel size and near fade + * follow the camera each frame it moves. */ export function createWildlifeDensity({ - cesium: C, - viewer, - positionForCell = (cell) => C.Cartesian3.fromDegrees(cell.lon, cell.lat), + engine, + positionForCell = (cell) => geo.cartesian3.fromDegrees(cell.lon, cell.lat), requestRender = () => {}, setTimer = (fn, delay) => setTimeout(fn, delay), clearTimer = (timer) => clearTimeout(timer), @@ -149,13 +177,8 @@ export function createWildlifeDensity({ documentRef = globalThis.document, } = {}) { let collection = null; - const nearFade = (width) => - new C.NearFarScalar( - width * WILDLIFE_DENSITY_NEAR_FADE.near, - 0, - width * WILDLIFE_DENSITY_NEAR_FADE.far, - 1, - ); + let removeFrame = null; + let lastView = null; let metadata = new WeakMap(); const entries = new Map(); const fading = new Set(); @@ -168,30 +191,81 @@ export function createWildlifeDensity({ const fadeQueue = createWildlifeWorkQueue({ requestIdle, cancelIdle }); const styleQueue = createWildlifeWorkQueue({ requestIdle, cancelIdle }); - // BillboardCollection does not implement Primitive's allowPicking option. - // Skipping its public pick update prevents its translucent quads from - // intercepting observations, including pixels with a faint glow alpha. - class DensityCollection extends C.BillboardCollection { - constructor() { - // Cesium's TRANSLUCENT-only option writes depth and occludes badges. - super(); - this.allowPicking = false; - } - update(frameState) { - if (frameState.passes?.pick) return; - return super.update(frameState); - } - } - function color(entry) { const mix = (i) => tint.low[i] + (tint.high[i] - tint.low[i]) * entry.rank; - return new C.Color( + return [ mix(0), mix(1), mix(2), - (wildlifeDensityIntensity(entry.rank) * entry.opacity * entry.fade) / - FADE_STEPS, + ((wildlifeDensityIntensity(entry.rank) * entry.opacity * entry.fade) / + FADE_STEPS) * + entry.near, + ]; + } + + // Pixel size and near fade of one splat for the camera `view`. + function layout({ position, width }, view) { + const pixels = wildlifeMetresToPixels(width, position, view); + const distance = Math.hypot( + position.x - view.position.x, + position.y - view.position.y, + position.z - view.position.z, ); + return { + scale: pixels === undefined ? 0 : pixels / SPRITE_SIZE, + show: pixels !== undefined, + near: wildlifeNearFarValue(wildlifeDensityNearFade(width), distance), + }; + } + + // Lays one splat out for `view`, writing only what changed; true when it + // wrote. + function place(entry, view) { + const { scale, show, near } = layout(entry.data, view); + let wrote = false; + if (entry.point.scale !== scale) { + entry.point.scale = scale; + wrote = true; + } + if (entry.point.show !== show) { + entry.point.show = show; + wrote = true; + } + if (entry.near !== near) { + entry.near = near; + entry.point.color = color(entry); + wrote = true; + } + return wrote; + } + + const sameView = (a, b) => + !!a && + !!b && + a.focalPx === b.focalPx && + a.position.x === b.position.x && + a.position.y === b.position.y && + a.position.z === b.position.z && + a.direction.x === b.direction.x && + a.direction.y === b.direction.y && + a.direction.z === b.direction.z; + + // Lays every splat out for the current camera; commits what changed. + function follow() { + if (!collection || !entries.size) return false; + const view = wildlifeCameraView(engine); + if (sameView(view, lastView)) return false; + lastView = view; + let wrote = false; + for (const entry of entries.values()) wrote = place(entry, view) || wrote; + if (wrote) collection.commit(); + return wrote; + } + const onFrame = () => follow(); + // Commit what the data passes wrote before asking for a frame. + function render(reason) { + collection?.commit(); + requestRender(reason); } function* styleWork() { @@ -207,7 +281,7 @@ export function createWildlifeDensity({ if (next === tint) return; tint = next; if (entries.size) - styleQueue.start(styleWork(), () => requestRender('density-style')); + styleQueue.start(styleWork(), () => render('density-style')); } function onStyle(event) { @@ -221,8 +295,18 @@ export function createWildlifeDensity({ function ensureCollection() { if (collection) return; - collection = new DensityCollection(); - viewer.scene.primitives.add(collection); + collection = createWildlifeSpriteBank(engine, { + id: WILDLIFE_DENSITY_SET_ID, + order: WILDLIFE_DENSITY_ORDER, + // Picks pass through the glow, including pixels with a faint alpha. + pickable: false, + // A depth-tested glow would hide under terrain and tiles; depth-free + // splats rely on the limb test below instead. + depthTest: 'always', + sprite: { atlas: DENSITY_ATLAS, sizePx: SPRITE_SIZE }, + }); + lastView = null; + removeFrame = engine.frames.preRender.addEventListener(onFrame); if (!listening) { tint = tintForStyle(documentRef?.documentElement?.dataset?.gevStyle); eventTarget?.addEventListener?.('gev:style-change', onStyle); @@ -247,7 +331,7 @@ export function createWildlifeDensity({ fadeTimer = setTimer(() => { fadeTimer = null; fadeQueue.start(fadeWork(), () => { - requestRender('density-fade'); + render('density-fade'); scheduleFade(); }); }, FADE_STEP_MS); @@ -314,7 +398,7 @@ export function createWildlifeDensity({ }; let sightingCount = 0; let changed = false; - const height = viewer.camera?.positionCartographic?.height ?? 0; + const height = engine.camera.height(); const opacity = Math.max( 0, Math.min( @@ -323,9 +407,13 @@ export function createWildlifeDensity({ (ALTITUDE_FADE_END - ALTITUDE_FADE_START), ), ); + const camera = engine.camera.livePosition; + // Splats written in this pass are laid out for this camera. + const view = wildlifeCameraView(engine); + let created = false; const insideHorizon = horizonTest( - viewer.camera?.positionWC, - C.Ellipsoid?.WGS84?.minimumRadius ?? 6_356_752.314245, + { x: camera.x, y: camera.y, z: camera.z }, + WGS84_MINIMUM_RADIUS, ); const rankOf = wildlifeDensityRanks(cells); for (const cell of cells) { @@ -355,21 +443,24 @@ export function createWildlifeDensity({ const rank = rankOf(data.count); let entry = entries.get(data.id); if (!entry) { + created ||= !collection; ensureCollection(); - entry = { data, rank, opacity, fade: 1, seen: token, point: null }; + const { scale, show, near } = layout(data, view); + entry = { + data, + rank, + opacity, + fade: 1, + near, + seen: token, + point: null, + }; entry.point = collection.add({ position: data.position, - image: DENSITY_IMAGE, - // Cesium packs width in 16 bits even for meter-sized billboards. - // Put the world diameter in its unrestricted scale attribute so - // continental kernels never clamp into thin vertical ellipses. - width: SPRITE_SIZE, - height: SPRITE_SIZE, - scale: data.width / SPRITE_SIZE, - sizeInMeters: true, + cell: 'glow', + scale, + show, color: color(entry), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - translucencyByDistance: nearFade(data.width), }); entries.set(data.id, entry); fading.add(entry); @@ -377,17 +468,11 @@ export function createWildlifeDensity({ changed = true; } else { entry.seen = token; - if (entry.data.position !== data.position) { + const moved = + entry.data.position !== data.position || + entry.data.width !== data.width; + if (entry.data.position !== data.position) entry.point.position = data.position; - operations.densityWrites++; - changed = true; - } - if (entry.data.width !== data.width) { - entry.point.scale = data.width / SPRITE_SIZE; - entry.point.translucencyByDistance = nearFade(data.width); - operations.densityWrites++; - changed = true; - } if (entry.rank !== rank || entry.opacity !== opacity) { entry.rank = rank; entry.opacity = opacity; @@ -396,6 +481,12 @@ export function createWildlifeDensity({ changed = true; } entry.data = data; + // A moved or resized splat is laid out again. + if (moved) { + place(entry, view); + operations.densityWrites++; + changed = true; + } } } for (const [id, entry] of entries) { @@ -413,12 +504,15 @@ export function createWildlifeDensity({ } if (!entries.size && collection) clear(true); else scheduleFade(); - if (changed) requestRender('density'); + // A new set is laid out for this camera; otherwise the next frame + // relays out whatever an earlier camera placed. + if (created && collection) lastView = view; + if (changed) render('density'); onOperations(operations); return { sightingCount, changed, size: entries.size }; } - /** Release the texture atlas, billboards, cached coordinates and all work. */ + /** Release the mover set, cached coordinates, frame listener and all work. */ function clear(keepMetadata = false) { generation++; fadeQueue.cancel(); @@ -428,12 +522,11 @@ export function createWildlifeDensity({ entries.clear(); fading.clear(); if (!keepMetadata) metadata = new WeakMap(); + removeFrame?.(); + removeFrame = null; + lastView = null; if (collection) { - if ( - viewer.scene.primitives.remove(collection) !== true && - !collection.isDestroyed?.() - ) - collection.destroy(); + collection.destroy(); collection = null; } if (listening) { diff --git a/src/layers/wildlifeSightings/density.test.mjs b/src/layers/wildlifeSightings/density.test.mjs index f4c610c7f..a7ec83817 100644 --- a/src/layers/wildlifeSightings/density.test.mjs +++ b/src/layers/wildlifeSightings/density.test.mjs @@ -1,16 +1,25 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { wildlifeGridCellBounds } from './gridViewport.js'; import { createWildlifeDensity, wildlifeDensityIntensity, + wildlifeDensityNearFade, wildlifeDensityRanks, wildlifeDensityKernel, WILDLIFE_DENSITY_MAX_ALPHA, WILDLIFE_DENSITY_NEAR_FADE, + WILDLIFE_DENSITY_ORDER, WILDLIFE_DENSITY_OVERLAP, + WILDLIFE_DENSITY_SET_ID, } from './density.js'; +import { + wildlifeCameraView, + wildlifeMetresToPixels, + wildlifeNearFarValue, +} from './display.js'; import { WILDLIFE_WORK_UNIT } from './performance.js'; function cell(column, count = 1, options = {}) { @@ -25,25 +34,37 @@ function cell(column, count = 1, options = {}) { }; } -function harness({ documentStyle = 'normal', camera = {} } = {}) { - const counts = { positions: 0, writes: 0, created: 0, updates: 0 }; - class BillboardCollection extends Cesium.BillboardCollection { - add(options) { - counts.created++; - const point = super.add(options); - return new Proxy(point, { - get: (target, key) => Reflect.get(target, key, target), - set(target, key, value) { - counts.writes++; - return Reflect.set(target, key, value, target); - }, - }); - } - update() { - counts.updates++; - } - } - const primitives = new Cesium.PrimitiveCollection(); +const ID = WILDLIFE_DENSITY_SET_ID; +const degrees = (value) => (value * Math.PI) / 180; + +/** A fake globe engine looking straight down at (lon, lat) from `height`. */ +function engineAt({ lon = 20, lat = 40, height = 2_000_000 } = {}) { + const engine = createFakeEngine(); + engine.fake.setPose({ + longitude: degrees(lon), + latitude: degrees(lat), + height, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }); + return engine; +} + +function harness({ documentStyle = 'normal', engine = engineAt() } = {}) { + const counts = { positions: 0, writes: 0, sets: 0 }; + // Every instance write the density makes, through the engine's mover set. + const createSet = engine.movers.createSet; + engine.movers.createSet = (options) => { + const set = createSet(options); + counts.sets++; + const write = set.set; + set.set = (index, fields) => { + counts.writes++; + return write(index, fields); + }; + return set; + }; let handle = 0; const timers = new Map(); const idle = new Map(); @@ -62,11 +83,10 @@ function harness({ documentStyle = 'normal', camera = {} } = {}) { }, }; const density = createWildlifeDensity({ - cesium: { ...Cesium, BillboardCollection }, - viewer: { scene: { primitives }, camera }, + engine, positionForCell(row) { counts.positions++; - return Cesium.Cartesian3.fromDegrees(row.lon, row.lat); + return geo.cartesian3.fromDegrees(row.lon, row.lat); }, requestRender: (reason) => renders.push(reason), setTimer(fn) { @@ -97,8 +117,8 @@ function harness({ documentStyle = 'normal', camera = {} } = {}) { flushIdle(); } return { + engine, density, - primitives, counts, renders, timers, @@ -107,6 +127,8 @@ function harness({ documentStyle = 'normal', camera = {} } = {}) { eventTarget, flushIdle, fadeStep, + /** The density's mover set as the engine holds it. */ + moverSet: () => engine.fake.moverSet(ID), settle() { while (timers.size || idle.size) { fadeStep(); @@ -116,6 +138,8 @@ function harness({ documentStyle = 'normal', camera = {} } = {}) { }; } +const alpha = (point) => point.color[3]; + test('density intensity rises with view rank, is bounded, and leaves no background for zero data', () => { for (const empty of [0, -1, null, undefined, NaN, Infinity]) assert.equal(wildlifeDensityIntensity(empty), 0); @@ -156,7 +180,8 @@ test('an empty density snapshot creates no glow', () => { cell(3, 1, { id: 'invalid' }), ]); assert.equal(h.density.collection, null); - assert.equal(h.primitives.length, 0); + assert.equal(h.moverSet(), undefined); + assert.equal(h.engine.frames.preRender.numberOfListeners, 0); assert.equal(h.timers.size, 0); h.density.destroy(); }); @@ -179,77 +204,114 @@ test('uniform occupied cells make a continuous field with no visible lattice', ( assert.ok(modulation < 0.007, `${(100 * modulation).toFixed(3)}% modulation`); }); -test('each glow kernel fades on the GPU as the camera comes inside it, scaled to its own width', () => { +test('each glow kernel is sized in metres and fades as the camera comes inside it, scaled to its own width', () => { const h = harness(); - h.density.update([cell(0, 4), cell(1, 9)]); + const cells = [cell(0, 4), cell(1, 9)]; + h.density.update(cells); + h.settle(); const collection = h.density.collection; assert.equal(collection.length, 2); - for (let i = 0; i < collection.length; i++) { - const point = collection.get(i); - const width = point.scale * point.width; - const fade = point.translucencyByDistance; - assert.ok(fade, 'no screen-filling kernel when zoomed inside it'); + const full = collection.handles.map(alpha); + const view = wildlifeCameraView(h.engine); + for (const [index, row] of cells.entries()) { + const point = collection.get(index); + const bounds = wildlifeGridCellBounds(row.id); + const metres = + WILDLIFE_DENSITY_OVERLAP * + ((2 * Math.PI * 6378137) / 360) * + Math.max( + (bounds.east - bounds.west) * Math.cos((row.lat * Math.PI) / 180), + bounds.north - bounds.south, + ); + const pixels = wildlifeMetresToPixels(metres, point.position, view); + assert.ok(Math.abs(point.scale * 128 - pixels) < 1e-6 * pixels); + const fade = wildlifeDensityNearFade(metres); assert.ok( - Math.abs(fade.near - width * WILDLIFE_DENSITY_NEAR_FADE.near) < 1e-6, + Math.abs(fade[0] - metres * WILDLIFE_DENSITY_NEAR_FADE.near) < 1e-6, ); - assert.equal(fade.nearValue, 0); + assert.equal(fade[1], 0); assert.ok( - Math.abs(fade.far - width * WILDLIFE_DENSITY_NEAR_FADE.far) < 1e-6, + Math.abs(fade[2] - metres * WILDLIFE_DENSITY_NEAR_FADE.far) < 1e-6, ); - assert.equal(fade.farValue, 1); + assert.equal(fade[3], 1); + assert.equal(wildlifeNearFarValue(fade, metres * 0.4), 0); + assert.equal(wildlifeNearFarValue(fade, metres * 3), 1); } + // Inside the first kernel: it vanishes instead of filling the screen. + const commits = h.moverSet().commits; + h.engine.fake.setPose({ + position: geo.cartesian3.fromDegrees(cells[0].lon, cells[0].lat, 50_000), + }); + h.engine.fake.frame(); + assert.equal(alpha(collection.get(0)), 0); + assert.ok(h.moverSet().commits > commits, 'a camera move relays out once'); + assert.ok( + collection.get(0).scale > collection.get(1).scale, + 'nearer kernels draw larger', + ); + const settled = h.moverSet().commits; + h.engine.fake.frame(); + assert.equal(h.moverSet().commits, settled, 'a still camera writes nothing'); + h.engine.fake.setPose({ + position: geo.cartesian3.fromDegrees(20, 40, 2_000_000), + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }); + h.engine.fake.frame(); + assert.deepEqual(collection.handles.map(alpha), full); h.density.destroy(); }); -test('occupied grid counts produce unlabelled world-sized glow billboards that never enter Cesium picking', () => { +test('occupied grid counts produce unlabelled world-sized glow sprites that never enter picking', () => { const h = harness(); const rows = [cell(0, 1), cell(1, 100), cell(2, 1000000)]; const result = h.density.update(rows); assert.equal(result.sightingCount, 1000101); - assert.equal(h.primitives.length, 1); - const collection = h.density.collection; - assert.equal(collection.allowPicking, false); - assert.equal(collection.length, 3); - const first = collection.get(0); + const set = h.moverSet(); + assert.equal(set.options.kind, 'sprite'); + assert.equal(set.options.pickable, false); + assert.equal(set.options.depthTest, 'always'); + assert.equal(set.options.order, WILDLIFE_DENSITY_ORDER); + assert.equal(set.store.count, 3); assert.equal( - first.id, + set.store.pickIds[0], undefined, 'density has no sighting identity or pick payload', ); - assert.equal(first.sizeInMeters, true); - assert.equal(first.scaleByDistance, undefined); - assert.match(decodeURIComponent(first.image), /radialGradient/); - assert.doesNotMatch(decodeURIComponent(first.image), /text|badge|raven/); + assert.equal(set.options.sprite.scaleByDistance, null); + const image = decodeURIComponent(set.options.sprite.atlas.url); + assert.match(image, /radialGradient/); + assert.doesNotMatch(image, /text|badge|raven/); + const first = h.density.collection.get(0); const bounds = wildlifeGridCellBounds(rows[0].id); const logicalHeight = (((bounds.north - bounds.south) * Math.PI) / 180) * 6378137; - assert.ok(first.width * first.scale >= 1.5 * logicalHeight); - assert.equal(first.width, first.height); assert.ok( - first.width <= 65536, - 'encoded dimensions fit Cesium width packing', + first.scale * set.options.sprite.sizePx >= + wildlifeMetresToPixels( + 1.5 * logicalHeight, + first.position, + wildlifeCameraView(h.engine), + ), ); - assert.ok( - first.width * first.scale > 65536, - 'continental splats retain their true footprint through scale', - ); - collection.update({ passes: { pick: true } }); - assert.equal( - h.counts.updates, - 0, - 'the real public pick update is suppressed', - ); - collection.update({ passes: { render: true } }); - assert.equal(h.counts.updates, 1); + const screen = h.engine.projector.worldToWindow(first.position); + assert.equal(h.engine.picker.pick(screen), undefined, 'picks pass through'); h.settle(); const rankOf = wildlifeDensityRanks(rows); - for (let i = 0; i < collection.length; i++) + for (let i = 0; i < h.density.collection.length; i++) assert.ok( Math.abs( - collection.get(i).color.alpha - + alpha(h.density.collection.get(i)) - wildlifeDensityIntensity(rankOf(rows[i].cellCount)), ) < 1e-12, ); + for (let i = 0; i < set.store.count; i++) + assert.ok( + Math.abs( + set.store.colors[i * 4 + 3] - alpha(h.density.collection.get(i)), + ) < 1e-6, + ); h.density.destroy(); }); @@ -259,25 +321,24 @@ test('progressive tile arrival fades only new cells and reuses existing glow wit const two = cell(1, 10); h.density.update([one]); const first = h.density.collection.get(0); - const starting = first.color.alpha; + const starting = alpha(first); h.fadeStep(); - assert.ok(first.color.alpha > starting); - const middle = first.color.alpha; + assert.ok(alpha(first) > starting); + const middle = alpha(first); h.density.update([one, two]); // The arriving tile re-ranks the incumbent (alone it was the hotspot; now // it is the median) but its fade progress is kept, not restarted. const progress = middle / wildlifeDensityIntensity(1); assert.ok( - Math.abs(first.color.alpha - wildlifeDensityIntensity(0.5) * progress) < - 1e-12, + Math.abs(alpha(first) - wildlifeDensityIntensity(0.5) * progress) < 1e-12, 'a streamed tile re-ranks but does not restart incumbents', ); assert.equal(h.density.collection.get(0), first); assert.equal(h.density.size, 2); h.settle(); - const settled = first.color.alpha; + const settled = alpha(first); h.density.update([one, two]); - assert.equal(first.color.alpha, settled); + assert.equal(alpha(first), settled); h.density.update([two]); assert.equal(h.density.size, 1); assert.equal(h.density.collection.length, 1); @@ -286,11 +347,11 @@ test('progressive tile arrival fades only new cells and reuses existing glow wit assert.equal( h.density.collection, null, - 'empty data releases the atlas and collection', + 'empty data releases the atlas and mover set', ); assert.equal( - h.primitives.length, - 0, + h.moverSet(), + undefined, 'no full-view fill remains over empty data', ); h.density.destroy(); @@ -321,32 +382,30 @@ test('visibility and antimeridian padding are applied on data/moveEnd work only' west: 179, east: -179, }; + // Over the antimeridian, so the wrapped kernel is in front of the limb. + h.engine.fake.setPose({ + position: geo.cartesian3.fromDegrees(-179.3, wrapped.lat, 2_000_000), + }); assert.equal(h.density.update([wrapped], { boxes: [crossed] }).size, 1); h.density.destroy(); }); test('density culls far-side cells and front-side kernels that would spill over the limb', () => { - const camera = { - positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 5_000_000), - positionCartographic: { height: 5_000_000 }, - }; - const h = harness({ camera }); - const occluder = new Cesium.EllipsoidalOccluder( - Cesium.Ellipsoid.WGS84, - camera.positionWC, - ); + const h = harness({ + engine: engineAt({ lon: 0, lat: 0, height: 5_000_000 }), + }); const cells = [ cell(0, 1, { lat: 0, lon: 0 }), cell(1, 2, { lat: 0, lon: 54 }), cell(2, 4, { lat: 0, lon: 110 }), ]; assert.equal( - occluder.isPointVisible(Cesium.Cartesian3.fromDegrees(54, 0)), + h.engine.projector.horizonVisible(geo.cartesian3.fromDegrees(54, 0)), true, 'the limb fixture has a visible centre but an overflowing footprint', ); const result = h.density.update(cells, { - isPointVisible: (anchor) => occluder.isPointVisible(anchor), + isPointVisible: (anchor) => h.engine.projector.horizonVisible(anchor), }); assert.equal(result.size, 1, 'only the fully front-side kernel can glow'); assert.equal( @@ -357,27 +416,35 @@ test('density culls far-side cells and front-side kernels that would spill over h.density.destroy(); }); -test('density fades from regional detail to a clear whole-globe view without frame work or count loss', () => { - const camera = { positionCartographic: { height: 3_000_000 } }; - const h = harness({ camera }); +test('density fades from regional detail to a clear whole-globe view without idle frame work or count loss', () => { + const h = harness({ engine: engineAt({ height: 3_000_000 }) }); + const at = (height) => + h.engine.fake.setPose({ + position: geo.cartesian3.fromDegrees(20, 40, height), + }); const cells = [cell(0, 12)]; h.density.update(cells); h.settle(); const point = h.density.collection.get(0); - const fullAlpha = point.color.alpha; - camera.positionCartographic.height = 5_500_000; + const fullAlpha = alpha(point); + at(5_500_000); h.density.update(cells); assert.equal(h.density.collection.get(0), point); - assert.ok(Math.abs(point.color.alpha - fullAlpha / 2) < 1e-12); + assert.ok(Math.abs(alpha(point) - fullAlpha / 2) < 1e-12); const writes = h.counts.writes; h.density.update(cells); assert.equal(h.counts.writes, writes, 'unchanged height writes nothing'); - camera.positionCartographic.height = 8_000_000; + at(8_000_000); assert.equal(h.density.update(cells).sightingCount, 12); assert.equal(h.density.collection, null); - assert.equal(h.timers.size, 0, 'no invisible fade or frame loop'); + assert.equal(h.timers.size, 0, 'no invisible fade'); + assert.equal( + h.engine.frames.preRender.numberOfListeners, + 0, + 'no frame listener without glow', + ); const positions = h.counts.positions; - camera.positionCartographic.height = 3_000_000; + at(3_000_000); h.density.update(cells); h.settle(); assert.equal( @@ -385,7 +452,7 @@ test('density fades from regional detail to a clear whole-globe view without fra positions, 'altitude changes reuse decoded positions', ); - assert.equal(h.density.collection.get(0).color.alpha, fullAlpha); + assert.equal(alpha(h.density.collection.get(0)), fullAlpha); h.density.destroy(); }); @@ -394,41 +461,46 @@ test('styles follow persisted mono/CRT and cockpit vision overrides, without add h.density.update([cell(0)]); h.settle(); const point = h.density.collection.get(0); - assert.equal(point.color.red, point.color.green); - assert.equal(point.color.green, point.color.blue); - const alpha = point.color.alpha; + const [red, green, blue] = [0, 1, 2]; + assert.equal(point.color[red], point.color[green]); + assert.equal(point.color[green], point.color[blue]); + const before = alpha(point); h.eventTarget.emit('gev:style-change', { style: 'retro' }); h.flushIdle(); - assert.ok(point.color.green > point.color.red); + assert.ok(point.color[green] > point.color[red]); h.eventTarget.emit('gev:vision-change', { cockpit: true, style: 'nvg' }); h.flushIdle(); - assert.equal(point.color.red, point.color.green); + assert.equal(point.color[red], point.color[green]); h.eventTarget.emit('gev:style-change', { style: 'normal' }); h.flushIdle(); assert.equal( - point.color.red, - point.color.green, + point.color[red], + point.color[green], 'cockpit vision wins while active', ); h.eventTarget.emit('gev:vision-change', { cockpit: false, style: 'normal' }); h.flushIdle(); - assert.notEqual(point.color.red, point.color.green); - assert.equal(point.color.alpha, alpha); + assert.notEqual(point.color[red], point.color[green]); + assert.equal(alpha(point), before); + const store = h.moverSet().store; + assert.ok(Math.abs(store.colors[1] - point.color[green]) < 1e-6); h.density.destroy(); }); -test('clear/disable and destroy release the atlas, primitives, coordinates, timers and listeners', () => { +test('clear/disable and destroy release the mover set, coordinates, timers and listeners', () => { const h = harness(); const row = cell(0); h.density.update([row]); - const old = h.density.collection; + const old = h.density.collection.set; assert.equal(h.timers.size, 1); + assert.equal(h.engine.frames.preRender.numberOfListeners, 1); h.fadeStep(); h.density.clear(); assert.equal(old.isDestroyed(), true); assert.equal(h.density.collection, null); assert.equal(h.density.size, 0); - assert.equal(h.primitives.length, 0); + assert.equal(h.moverSet(), undefined); + assert.equal(h.engine.frames.preRender.numberOfListeners, 0); assert.equal(h.timers.size, 0); assert.equal(h.idle.size, 0); for (const values of h.listeners.values()) assert.equal(values.size, 0); @@ -448,13 +520,16 @@ test('clear/disable and destroy release the atlas, primitives, coordinates, time 'obsolete data work cannot resurrect a destroyed layer', ); assert.equal(h.density.collection, null); - assert.equal(h.primitives.length, 0); + assert.equal(h.moverSet(), undefined); assert.equal(h.timers.size, 0); assert.equal(h.idle.size, 0); }); test('dense six-tile updates have a deterministic linear operation budget and no warmed writes/projections', (t) => { - const h = harness(); + // High above the six tiles: most kernels are in front of the limb. + const h = harness({ + engine: engineAt({ lon: -112.5, lat: 40, height: 7_900_000 }), + }); const cells = []; for (let x = 1; x < 3; x++) for (let y = 1; y < 4; y++) @@ -479,7 +554,9 @@ test('dense six-tile updates have a deterministic linear operation budget and no assert.equal(operations.densityVisits, 6144); assert.equal(operations.densityMetadataBuilds, 6144); assert.equal(operations.densityPositionBuilds, 6144); - assert.equal(operations.densityCreated, 6144); + const drawn = operations.densityCreated; + assert.ok(drawn > 5000, `${drawn} kernels drawn`); + assert.equal(h.moverSet().store.count, drawn); const positions = h.counts.positions; const writes = h.counts.writes; let units = 0; @@ -491,18 +568,22 @@ test('dense six-tile updates have a deterministic linear operation budget and no while (!work.next().done) units++; assert.deepEqual(operations, { densityVisits: 6144, - densityRetirementVisits: 6144, + densityRetirementVisits: drawn, densityMetadataBuilds: 0, densityPositionBuilds: 0, densityWrites: 0, densityCreated: 0, densityRemoved: 0, }); - assert.equal(units, Math.ceil(6144 / WILDLIFE_WORK_UNIT) * 2); + assert.equal( + units, + Math.ceil(6144 / WILDLIFE_WORK_UNIT) + + Math.ceil(drawn / WILDLIFE_WORK_UNIT), + ); assert.equal(h.counts.positions, positions); assert.equal(h.counts.writes, writes); t.diagnostic( - `6,144 occupied cells: ${units} bounded units, 12,288 linear visits, zero warmed metadata/Cartesian builds, projections or billboard writes`, + `6,144 occupied cells (${drawn} drawn): ${units} bounded units, ${6144 + drawn} linear visits, zero warmed metadata/Cartesian builds, projections or sprite writes`, ); h.density.destroy(); }); diff --git a/src/layers/wildlifeSightings/display.js b/src/layers/wildlifeSightings/display.js new file mode 100644 index 000000000..49575f56a --- /dev/null +++ b/src/layers/wildlifeSightings/display.js @@ -0,0 +1,216 @@ +// The wildlife layer's drawing on the globe contract (engine.movers and +// engine.lines), the same on every engine. A sprite bank keeps one sprite +// mover set and hands out handles whose index moves when another handle is +// removed; the set is rebuilt larger when it fills, or with a new atlas. + +const FIRST_CAPACITY = 64; +const FIELDS = ['position', 'color', 'scale', 'cell', 'show']; + +/** One sprite of a bank. Writes go to the bank's mover set at once. */ +class WildlifeSprite { + constructor(bank, fields) { + this.bank = bank; + this.index = -1; + this.fields = fields; + } + get position() { + return this.fields.position; + } + set position(value) { + this.bank.write(this, { position: value }); + } + get show() { + return this.fields.show; + } + set show(value) { + this.bank.write(this, { show: value === true }); + } + get color() { + return this.fields.color; + } + set color(value) { + this.bank.write(this, { color: value }); + } + get scale() { + return this.fields.scale; + } + set scale(value) { + this.bank.write(this, { scale: value }); + } + get cell() { + return this.fields.cell; + } + set cell(value) { + this.bank.write(this, { cell: value }); + } + get id() { + return this.fields.pickId; + } +} + +/** + * A growing sprite mover set: `add(fields)` returns a handle with position, + * show, color, scale and cell properties; `id` becomes the instance pickId. + * Writes are stored at once and drawn after commit(). + * @param {object} engine The globe engine. + * @param {{ id: string, order?: number, pickable?: boolean, + * depthTest?: 'normal'|'always'|{ disableWithinM: number }, + * sprite: { atlas: object, sizePx: number, scaleByDistance?: number[] } }} options + */ +export function createWildlifeSpriteBank(engine, options) { + const handles = []; + let atlas = options.sprite.atlas; + let set = null; + let capacity = 0; + let dirty = false; + + function full(handle) { + const fields = {}; + for (const key of FIELDS) + if (handle.fields[key] !== undefined) fields[key] = handle.fields[key]; + if ('pickId' in handle.fields) fields.pickId = handle.fields.pickId; + return fields; + } + + function build(wanted) { + set?.destroy(); + set = engine.movers.createSet({ + id: options.id, + kind: 'sprite', + capacity: wanted, + order: options.order ?? 0, + depthTest: options.depthTest ?? 'always', + pickable: options.pickable === true, + sprite: { ...options.sprite, atlas }, + }); + capacity = wanted; + handles.forEach((handle, index) => set.set(index, full(handle))); + set.setCount(handles.length); + dirty = true; + } + + function ensure(count) { + if (set && count <= capacity) return; + let next = Math.max(FIRST_CAPACITY, capacity); + while (next < count) next *= 2; + build(next); + } + + return { + /** A new sprite: { position, cell?, color?, scale?, show?, id? }. */ + add({ id, ...rest } = {}) { + const fields = { color: [1, 1, 1, 1], scale: 1, show: true, ...rest }; + if (id !== undefined) fields.pickId = id; + const handle = new WildlifeSprite(this, fields); + ensure(handles.length + 1); + handle.index = handles.length; + handles.push(handle); + set.set(handle.index, full(handle)); + set.setCount(handles.length); + dirty = true; + return handle; + }, + /** Removes a sprite; the last one takes its index. */ + remove(handle) { + if (handle?.bank !== this || handle.index < 0) return false; + const last = handles.pop(); + if (last !== handle) { + handles[handle.index] = last; + last.index = handle.index; + set.set(last.index, full(last)); + } + handle.index = -1; + set.setCount(handles.length); + dirty = true; + return true; + }, + removeAll() { + for (const handle of handles) handle.index = -1; + handles.length = 0; + set?.setCount(0); + dirty = true; + }, + write(handle, fields) { + Object.assign(handle.fields, fields); + if (handle.index < 0 || !set) return; + set.set(handle.index, fields); + dirty = true; + }, + /** Rebuilds the set on another atlas (same cells), keeping every sprite. */ + setAtlas(next) { + if (next === atlas) return false; + atlas = next; + if (set) build(capacity); + return true; + }, + /** Uploads what changed since the last commit; false when nothing did. */ + commit() { + if (!dirty || !set) return false; + dirty = false; + set.commit(); + return true; + }, + get length() { + return handles.length; + }, + get(index) { + return handles[index]; + }, + get handles() { + return handles; + }, + get set() { + return set; + }, + destroy() { + for (const handle of handles) handle.index = -1; + handles.length = 0; + set?.destroy(); + set = null; + capacity = 0; + dirty = false; + }, + }; +} + +/** + * Cesium's NearFarScalar interpolation over [nearM, nearValue, farM, + * farValue]: over the squared camera distance, eased by ^0.2. + */ +export function wildlifeNearFarValue(scalar, distance) { + const [near, nearValue, far, farValue] = scalar; + const nearSq = near * near; + const farSq = far * far; + if (!(farSq > nearSq)) return distance <= near ? nearValue : farValue; + const t = Math.min( + 1, + Math.max(0, (distance * distance - nearSq) / (farSq - nearSq)), + ); + return nearValue + (farValue - nearValue) * t ** 0.2; +} + +/** + * The CSS-pixel diameter of a camera-facing square `metres` wide at + * `position`, as Cesium draws a billboard sized in metres: its eye-space + * depth along the view direction sets the size. Undefined behind the camera. + */ +export function wildlifeMetresToPixels(metres, position, view) { + const dx = position.x - view.position.x; + const dy = position.y - view.position.y; + const dz = position.z - view.position.z; + const depth = + dx * view.direction.x + dy * view.direction.y + dz * view.direction.z; + if (!(depth > 0)) return undefined; + return (metres * view.focalPx) / depth; +} + +/** The camera as wildlifeMetresToPixels reads it, from the engine. */ +export function wildlifeCameraView(engine) { + const pose = engine.camera.pose(); + const { height } = engine.host.size(); + return { + position: pose.position, + direction: pose.direction, + focalPx: height / (2 * Math.tan(pose.fovy / 2)), + }; +} diff --git a/src/layers/wildlifeSightings/docs.test.mjs b/src/layers/wildlifeSightings/docs.test.mjs index 794bd62b5..945f9e373 100644 --- a/src/layers/wildlifeSightings/docs.test.mjs +++ b/src/layers/wildlifeSightings/docs.test.mjs @@ -80,7 +80,7 @@ test('Wildlife docs record queue failure isolation and the complete badge mappin }); test('Wildlife docs separate density from real observation icons and define persistent selection', () => { - assert.match(section, /dedicated, nonpickable billboard/); + assert.match(section, /dedicated, nonpickable sprite/); assert.match(section, /view-relative density ramp/); assert.match(section, /four logical cell widths/); assert.match( @@ -107,7 +107,7 @@ test('Wildlife docs describe one source link, name declutter and globe glow limi assert.match(section, /3,000 km/); assert.match(section, /8,000 km/); assert.match(section, /5% radius margin/); - assert.match(section, /add no per-frame work/); + assert.match(section, /only on frames where the camera moved/); }); test('Wildlife docs cite the documented UTFGrid endpoint and the Acartia key request', () => { diff --git a/src/layers/wildlifeSightings/gridViewport.js b/src/layers/wildlifeSightings/gridViewport.js index 0fdf421b0..e651c9d6a 100644 --- a/src/layers/wildlifeSightings/gridViewport.js +++ b/src/layers/wildlifeSightings/gridViewport.js @@ -115,10 +115,8 @@ export function wildlifeGridTiles({ boxes, z } = {}) { } /** Unlike detail-row viewport queries, grids cover full globe views too. */ -export function wildlifeGridViewportBoxes(viewer) { - const rectangle = viewer?.camera?.computeViewRectangle?.( - viewer?.scene?.globe?.ellipsoid, - ); +export function wildlifeGridViewportBoxes(engine) { + const rectangle = engine?.camera?.viewRectangle(); if (!rectangle) return []; return normalizedBoxes([ Object.fromEntries( diff --git a/src/layers/wildlifeSightings/gridViewport.test.mjs b/src/layers/wildlifeSightings/gridViewport.test.mjs index a1b843510..23934a890 100644 --- a/src/layers/wildlifeSightings/gridViewport.test.mjs +++ b/src/layers/wildlifeSightings/gridViewport.test.mjs @@ -9,6 +9,7 @@ import { WILDLIFE_GRID_LOGICAL_SIZE, WILDLIFE_GRID_TILE_CAP, } from './gridViewport.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; const globe = [ { south: -85.0511287798066, west: -180, north: 85.0511287798066, east: 180 }, @@ -98,22 +99,21 @@ test('tile cap degrades zoom to preserve complete coverage and oversized direct test('grid camera viewport supports globe and antimeridian without detail-query degree limits', () => { const radians = (value) => (value * Math.PI) / 180; - const viewer = { - camera: { - computeViewRectangle: () => ({ - south: radians(-80), - west: radians(120), - north: radians(80), - east: radians(-120), - }), - }, - }; - const boxes = wildlifeGridViewportBoxes(viewer); + const engine = createFakeEngine(); + engine.fake.script('viewRectangle', { + south: radians(-80), + west: radians(120), + north: radians(80), + east: radians(-120), + }); + const boxes = wildlifeGridViewportBoxes(engine); assert.equal(boxes.length, 2); assert.ok(Math.abs(boxes[0].west - 120) < 1e-8); assert.equal(boxes[0].east, 180); assert.equal(boxes[1].west, -180); - assert.deepEqual(wildlifeGridViewportBoxes({}), []); + engine.fake.script('viewRectangle', undefined); + assert.deepEqual(wildlifeGridViewportBoxes(engine), []); + assert.deepEqual(wildlifeGridViewportBoxes(null), []); }); test('logical cell ids and bounds share geographic coverage and wrap antimeridian', () => { diff --git a/src/layers/wildlifeSightings/index.js b/src/layers/wildlifeSightings/index.js index 75cedb093..779d36edd 100644 --- a/src/layers/wildlifeSightings/index.js +++ b/src/layers/wildlifeSightings/index.js @@ -1,19 +1,17 @@ -import * as Cesium from 'cesium'; -import { horizonOccluder } from '../../data/iconOrientation.js'; import { createPhotoSlots } from './photos.js'; import { createRendering } from './rendering.js'; import { createSelection } from './selection.js'; import { createControls } from './controls.js'; import { createLifecycle } from './lifecycle.js'; -/** Compose recent wildlife observations without acquiring data at construction. */ +/** Compose recent wildlife observations without acquiring data at construction. + * The layer draws on the globe engine it is attached to (attachRenderer) and + * keeps its observations, filters and selection while detached. */ export function createWildlifeSightingsLayer({ source, overlayHost = null, - screenSpaceEventHandlerFactory = null, picking = null, pointer = null, - sprites = null, badgeSprites = null, render = null, context = null, @@ -26,15 +24,11 @@ export function createWildlifeSightingsLayer({ cancelIdleCallback, imageFactory, eventTarget = globalThis.window, - cesium = Cesium, - project = (scene, position) => - Cesium.SceneTransforms.worldToWindowCoordinates(scene, position), - occluderFactory = horizonOccluder, } = {}) { if (typeof source?.getSnapshot !== 'function') throw new TypeError('Wildlife sightings require a snapshot source'); const state = { - viewer: null, + engine: null, enabled: false, destroyed: false, manager: null, @@ -43,15 +37,14 @@ export function createWildlifeSightingsLayer({ cameraTimer: null, removeCamera: null, removeContext: null, - clickHandler: null, + removeClick: null, rows: new Map(), renderedRows: [], rowById: new Map(), - points: null, + badges: null, pointById: new Map(), positions: new Map(), cells: [], - staging: null, selectedId: null, selectedRow: null, carriers: new Map(), @@ -84,10 +77,8 @@ export function createWildlifeSightingsLayer({ const services = { source, overlayHost, - screenSpaceEventHandlerFactory, picking, pointer, - sprites, badgeSprites, render, context, @@ -99,9 +90,6 @@ export function createWildlifeSightingsLayer({ requestIdleCallback, cancelIdleCallback, eventTarget, - cesium, - project, - occluderFactory, }; const owner = { state, services, parts }; parts.photos = createPhotoSlots({ diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index 0fe430fbe..460bef739 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -125,15 +125,14 @@ export function createLifecycle({ state, services, parts }) { } function currentQuery() { - const height = state.viewer?.camera?.positionCartographic?.height; + const engine = state.engine; + const height = engine?.camera.height(); state.band = getWildlifeHeightBand(height ?? Infinity, state.band); const boxes = state.band === 'global' ? null - : wildlifeViewportBoxes(state.viewer)?.map((box) => - snapWildlifeBox(box), - ); - const visibleBoxes = wildlifeGridViewportBoxes(state.viewer); + : wildlifeViewportBoxes(engine)?.map((box) => snapWildlifeBox(box)); + const visibleBoxes = wildlifeGridViewportBoxes(engine); const scope = state.band === 'global' || (!boxes && (height ?? Infinity) > WILDLIFE_GRID_HEIGHT_METERS) @@ -147,12 +146,12 @@ export function createLifecycle({ state, services, parts }) { state.confirmed, state.taxa, ]); - const canvas = state.viewer?.scene?.canvas; + const size = engine?.host.size(); const zoom = visibleBoxes?.length ? chooseWildlifeGridZoom({ boxes: visibleBoxes, - width: canvas?.clientWidth || 1400, - height: canvas?.clientHeight || 900, + width: size?.width || 1400, + height: size?.height || 900, }) : 0; const tiles = visibleBoxes?.length @@ -500,11 +499,52 @@ export function createLifecycle({ state, services, parts }) { }); } - function disable() { + function onMoveEnd() { + if (!state.enabled) return; + wildlifeMeasure('moveEnd', () => { + if (initialCameraDeadline && services.now() < initialCameraDeadline) { + initialCameraDeadline = services.now() + INITIAL_CAMERA_SETTLE_MS; + refreshView(); + initialSettle?.schedule(); + } else { + refreshView(); + scheduleRefresh(); + } + parts.controls.notify(); + }); + } + + // Draw the retained observations on the attached engine and listen to it. + function attachScene() { + parts.rendering.activate?.(); + refreshView(); + for (const id of parts.selection.sourceIds) + services.overlayHost?.setVisible?.(id, true); + parts.selection.install(); + services.picking?.registerPickOwner?.( + 'wildlife-sightings', + (id) => + typeof id === 'string' && + id.startsWith('wildlife:') && + state.rowById.has(id.slice(9)), + ); + state.removeCamera = + state.engine.camera.moveEnd.addEventListener(onMoveEnd); + } + + function releaseScene() { + state.removeCamera?.(); + state.removeCamera = null; + parts.rendering.deactivate?.(); + parts.selection.uninstall?.(); + services.picking?.unregisterPickOwner?.('wildlife-sightings'); + } + + // Requests, timers and debounces in flight end with their scene or lifetime. + function stopAcquisition() { lifetimeEpoch += 1; initialSettle?.finish(); initialCameraDeadline = 0; - state.enabled = false; services.clearTimer(state.cameraTimer); state.cameraTimer = null; state.request?.abort(); @@ -514,13 +554,16 @@ export function createLifecycle({ state, services, parts }) { state.background = null; state.loading = false; clearPending(); - state.removeCamera?.(); - state.removeCamera = null; - parts.rendering.deactivate?.(); + } + + function disable() { + stopAcquisition(); + state.enabled = false; + clearPending(); + releaseScene(); removeManagerListener?.(); removeManagerListener = null; parts.selection.remove(); - services.picking?.unregisterPickOwner?.('wildlife-sightings'); parts.rendering.clear(); parts.photos.clear(); parts.controls.notify(); @@ -529,55 +572,54 @@ export function createLifecycle({ state, services, parts }) { } const methods = { - /** Attach this layer's Cesium resources to one viewer. */ - init(viewer, { signal } = {}) { - if (state.viewer) + /** Attach this layer to the scene's globe engine. */ + init(_viewer, options = {}) { + if (state.engine) throw new Error('Wildlife sightings layer is already initialized'); + return methods.attachRenderer(_viewer, options); + }, + /** Draw on `engine` (a scene switch brings another); observations, + * filters and selection carry over. */ + attachRenderer(_viewer, { engine = null, signal = null } = {}) { if (state.destroyed || signal?.aborted) return false; - state.viewer = viewer; + if (state.engine) + throw new Error('Wildlife sightings display is already attached'); + if (!engine) + throw new TypeError('Wildlife sightings need a globe engine'); + state.engine = engine; parts.rendering.init(); + if (state.enabled) { + attachScene(); + if (state.selectedId) { + parts.selection.publishSelected(); + parts.selection.notify(); + } + scheduleRefresh(); + parts.controls.notify(); + } return true; }, + /** Release everything drawn on the engine; the data stays. */ + detachRenderer() { + if (!state.engine) return; + stopAcquisition(); + if (state.enabled) { + releaseScene(); + parts.selection.detach(); + } + parts.rendering.destroy(); + state.engine = null; + parts.controls.notify(); + }, /** Show retained observations and acquire the settled camera view. */ enable(_viewer, { signal } = {}) { if (state.destroyed || signal?.aborted) return false; - if (!state.viewer) + if (!state.engine) throw new Error('Wildlife sightings layer must be initialized'); if (state.enabled) return true; state.enabled = true; initialCameraDeadline = services.now() + INITIAL_CAMERA_SETTLE_MS; - parts.rendering.activate?.(); - state.points.show = true; - refreshView(); - for (const id of parts.selection.sourceIds) - services.overlayHost?.setVisible?.(id, true); - parts.selection.install(); - services.picking?.registerPickOwner?.( - 'wildlife-sightings', - (id) => - typeof id === 'string' && - id.startsWith('wildlife:') && - state.rowById.has(id.slice(9)), - ); - state.removeCamera = state.viewer.camera?.moveEnd?.addEventListener?.( - () => { - if (!state.enabled) return; - wildlifeMeasure('moveEnd', () => { - if ( - initialCameraDeadline && - services.now() < initialCameraDeadline - ) { - initialCameraDeadline = services.now() + INITIAL_CAMERA_SETTLE_MS; - refreshView(); - initialSettle?.schedule(); - } else { - refreshView(); - scheduleRefresh(); - } - parts.controls.notify(); - }); - }, - ); + attachScene(); // A debounce can expire while the manager is still enabling and cannot // accept refreshLayer yet. Its settled visibility event is the one // catch-up edge; completed initial queries are skipped by their key. @@ -591,19 +633,13 @@ export function createLifecycle({ state, services, parts }) { ) scheduleRefresh(); }) || null; - if (services.sprites?.restoreSpriteOrderOnEnable) - services.sprites.restoreSpriteOrderOnEnable( - 'wildlife-sightings', - state.viewer, - ); - else services.sprites?.restoreSpriteOrder?.(state.viewer); return true; }, /** Refresh observations and density independently for the current filters. */ async update(_viewer, { signal } = {}) { if ( !state.enabled || - !state.points || + !state.engine || state.destroyed || signal?.aborted ) { @@ -827,6 +863,7 @@ export function createLifecycle({ state, services, parts }) { destroy() { if (state.destroyed) return; disable(); + methods.detachRenderer(); state.destroyed = true; parts.selection.destroy(); parts.photos.destroy(); @@ -841,7 +878,6 @@ export function createLifecycle({ state, services, parts }) { state.failedParams = null; state.controlsListener = null; state.manager = null; - state.viewer = null; }, }; return { diff --git a/src/layers/wildlifeSightings/ownership.test.mjs b/src/layers/wildlifeSightings/ownership.test.mjs index e118a87bc..c11a67534 100644 --- a/src/layers/wildlifeSightings/ownership.test.mjs +++ b/src/layers/wildlifeSightings/ownership.test.mjs @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { ENGINE_KINDS } from '../../globe/contract.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { createWildlifeSightingsLayer } from './index.js'; +import { WILDLIFE_BADGE_SET_ID } from './rendering.js'; +import { WILDLIFE_DENSITY_SET_ID } from './density.js'; import { layerFeedState } from '../../data/feedState.js'; import { LayerLifecycle } from '../../data/lifecycle.js'; import { ICONIC_LEGEND_ORDER, WILDLIFE_MAX_ROWS } from './policy.js'; @@ -38,96 +42,81 @@ const photographed = { }, }; -function event() { - const listeners = new Set(); - return { - addEventListener(fn) { - listeners.add(fn); - return () => listeners.delete(fn); - }, - emit(value) { - for (const fn of [...listeners]) fn(value); - }, - count: () => listeners.size, - }; -} - -function primitives() { - const values = []; - const primitiveAdded = event(); - const primitiveRemoved = event(); - return { - values, - primitiveAdded, - primitiveRemoved, - get length() { - return values.length; - }, - get: (index) => values[index], - add(value) { - values.push(value); - primitiveAdded.emit(value); - return value; - }, - remove(value) { - const index = values.indexOf(value); - if (index < 0) return false; - values.splice(index, 1); - primitiveRemoved.emit(value); - value.destroy?.(); - return true; - }, - }; -} - -class PointCollection { - values = []; - show = false; - add(point) { - this.values.push(point); - return point; - } - remove(point) { - const index = this.values.indexOf(point); - if (index < 0) return false; - this.values.splice(index, 1); - return true; - } - removeAll() { - this.values = []; - } - destroy() { - this.destroyed = true; - } -} -class Ground { - constructor(options) { - Object.assign(this, options); - this.ready = false; - } - destroy() { - this.destroyed = true; - } - isDestroyed() { - return !!this.destroyed; - } -} +const toRadians = geo.math.toRadians; +const toDegrees = geo.math.toDegrees; +const cartographicOf = (position) => geo.cartographic.fromCartesian(position); +const heightOf = (position) => cartographicOf(position).height; +const lonOf = (position) => toDegrees(cartographicOf(position).longitude); +const latOf = (position) => toDegrees(cartographicOf(position).latitude); +/** + * The layer on a fake globe engine above Seattle. `extra.project(_, position)` + * and `extra.occluderFactory()` script the engine's projection and horizon + * test; the rest goes to the layer. + */ function harness( source = { getSnapshot: async () => [base] }, extra = {}, { autoEnable = true, width = 2000, height = 1200 } = {}, ) { + const { project, occluderFactory, ...layerExtra } = extra; let time = NOW; - let picked = null; + let picked; let cardHit = null; let pointerFree = true; + let tilesLoaded = true; let rectangle = { south: 47.4, west: -122.7, north: 47.9, east: -122.1 }; const events = new EventTarget(); - const moveEnd = event(); - const postRender = event(); - const pointOwners = primitives(); - const groundOwners = primitives(); + // Camera heights are set exactly; the pose stays above Seattle. + const camera = { height: 30_000 }; + const projectRow = + project ?? + ((_scene, position) => ({ + x: 400 + (lonOf(position) - (rectangle.west + rectangle.east) / 2) * 1000, + y: + 400 + + ((rectangle.south + rectangle.north) / 2 - latOf(position)) * 1000, + })); + // Input registrations the layer holds, on every engine. + let inputs = 0; + /** A fake engine of `kind` that sees and projects the fixture view. */ + function globe(kind) { + const engine = createFakeEngine({ kind, width, height }); + engine.fake.setPose({ + position: geo.cartesian3.fromDegrees(-122, 47, 30_000), + }); + // Settle the camera so later frames raise no camera events. + engine.fake.frames(2); + engine.camera.height = () => camera.height; + engine.fake.script('viewRectangle', () => + Object.fromEntries( + Object.entries(rectangle).map(([key, value]) => [ + key, + toRadians(value), + ]), + ), + ); + engine.projector.horizonVisible = (position) => + occluderFactory ? occluderFactory().isPointVisible(position) : true; + engine.fake.script('worldToWindow', (position) => + projectRow(null, position), + ); + engine.fake.script('pick', () => picked ?? undefined); + engine.fake.script('tilesLoaded', () => tilesLoaded); + const on = engine.input.on; + engine.input.on = (type, listener, options) => { + const off = on(type, listener, options); + inputs++; + let removed = false; + return () => { + if (!removed) inputs--; + removed = true; + off(); + }; + }; + return engine; + } + const engine = globe(); const owners = new Map(); const overlay = { entries: new Map(), @@ -137,47 +126,12 @@ function harness( const renders = []; const focus = []; const links = []; - const credits = new Set(); const store = { entities: new Map(), selectedEntityId: null }; - const clicks = {}; const timers = new Map(); const idleTasks = new Map(); let nextTimer = 0; let nextIdle = 0; const images = []; - const viewer = { - scene: { - primitives: pointOwners, - groundPrimitives: groundOwners, - postRender, - canvas: { clientWidth: width, clientHeight: height }, - globe: { - tileLoadProgressEvent: event(), - terrainProviderChanged: event(), - }, - sampleHeightSupported: true, - sampleHeight: () => - assert.fail('scene.sampleHeight renders per call; use groundFloor'), - pick: () => picked, - requestRender: () => assert.fail('must use render governor'), - }, - camera: { - positionCartographic: { height: 30_000 }, - positionWC: Cesium.Cartesian3.fromDegrees(-122, 47, 30_000), - moveEnd, - computeViewRectangle: () => - Object.fromEntries( - Object.entries(rectangle).map(([key, value]) => [ - key, - Cesium.Math.toRadians(value), - ]), - ), - }, - creditDisplay: { - addStaticCredit: (credit) => credits.add(credit), - removeStaticCredit: (credit) => credits.delete(credit), - }, - }; const ground = { sample: () => 123, resolves: 0 }; const layer = createWildlifeSightingsLayer({ source, @@ -187,12 +141,6 @@ function harness( ground.resolves++; }, }, - cesium: { - ...Cesium, - BillboardCollection: PointCollection, - GroundPrimitive: Ground, - GroundPolylinePrimitive: Ground, - }, now: () => time, setTimer(fn, delay) { const id = ++nextTimer; @@ -212,24 +160,6 @@ function harness( return image; }, eventTarget: events, - occluderFactory: () => ({ - isPointVisible: (position) => position.visible !== false, - }), - project: (_scene, position) => { - const cartographic = Cesium.Cartographic.fromCartesian(position); - return { - x: - 400 + - (Cesium.Math.toDegrees(cartographic.longitude) - - (rectangle.west + rectangle.east) / 2) * - 1000, - y: - 400 + - ((rectangle.south + rectangle.north) / 2 - - Cesium.Math.toDegrees(cartographic.latitude)) * - 1000, - }; - }, overlayHost: { setEntries: (id, entries, options) => { assert.equal(options.moving, false); @@ -241,33 +171,13 @@ function harness( hitTest: (_x, _y, { sourceId }) => cardHit?.sourceId === sourceId ? cardHit : null, }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction(fn) { - clicks.callback = fn; - }, - destroy() { - clicks.destroyed = true; - clicks.callback = null; - }, - }), pointer: { isPointerFree: () => pointerFree }, picking: { registerPickOwner: (id, fn) => owners.set(id, fn), unregisterPickOwner: (id) => owners.delete(id), - resolvePickId: (pick) => pick?.id ?? pick?.primitive?.id, + resolvePickId: (pick) => pick?.id ?? null, isOwnedByOtherLayer: (_layerId, id) => id?.startsWith('other:'), }, - sprites: { - registerSpriteCollection: (_id, collection) => { - clicks.collection = collection; - }, - unregisterSpriteCollection: () => { - clicks.unregistered = true; - }, - restoreSpriteOrderOnEnable: (id) => { - assert.equal(id, 'wildlife-sightings'); - }, - }, render: { governorRequestRender: (reason) => renders.push(reason) }, focus: { requestWorldFocus: (request) => focus.push(request) }, openExternal: (url) => links.push(url), @@ -288,11 +198,12 @@ function harness( }, removeEntityContextsForLayer: () => store.entities.clear(), }, - ...extra, + ...layerExtra, }); + const init = () => layer.init(null, { engine }); if (autoEnable) { - layer.init(viewer); - layer.enable(viewer); + init(); + layer.enable(); } function flushIdle() { for (let steps = 0; idleTasks.size; steps++) { @@ -302,9 +213,53 @@ function harness( fn({ timeRemaining: () => 10 }); } } + const badgeSet = (target = engine) => + target.fake.moverSet(WILDLIFE_BADGE_SET_ID); + // One badge as the engine draws it, read live from the mover store. + const badge = (id, target) => { + const index = () => { + const set = badgeSet(target); + if (!set) return -1; + for (let at = 0; at < set.store.count; at++) + if (set.store.pickIds[at] === id) return at; + return -1; + }; + const read = (fn) => { + const at = index(); + return at < 0 ? undefined : fn(badgeSet(target).store, at); + }; + return { + id, + get position() { + return read((store, at) => + geo.cartesian3.create( + store.positions[at * 3], + store.positions[at * 3 + 1], + store.positions[at * 3 + 2], + ), + ); + }, + get show() { + return read((store, at) => store.shows[at] === 1); + }, + get scale() { + return read((store, at) => store.scales[at]); + }, + get color() { + return read((store, at) => [ + ...store.colors.subarray(at * 4, at * 4 + 4), + ]); + }, + get cell() { + return read((store, at) => store.cellKeys[store.cells[at]]); + }, + }; + }; return { + engine, ground, layer, + init, async update(...args) { // Explicit refreshes stand for successive poll batches, not push bursts. time += 500; @@ -313,22 +268,48 @@ function harness( return result; }, flushIdle, - viewer, overlay, - clicks, owners, renders, focus, links, - credits, images, - groundOwners, - pointOwners, events, - postRender, - moveEnd, store, timers, + /** The badges an engine draws, in instance order. */ + badges(target = engine) { + const set = badgeSet(target); + return set + ? Array.from({ length: set.store.count }, (_, at) => + badge(set.store.pickIds[at], target), + ) + : []; + }, + globe, + badgeSet, + glow: () => engine.fake.moverSet(WILDLIFE_DENSITY_SET_ID), + moveEnd: { + emit: () => engine.fake.emit('camera.moveEnd'), + count: () => engine.camera.moveEnd.numberOfListeners, + }, + /** Listeners that run on every drawn frame. */ + frameWork: () => engine.frames.preRender.numberOfListeners, + /** The surface-loading watch (one per enabled scene). */ + surfaceWatch: () => engine.frames.postRender.numberOfListeners, + /** Surface loading as the engine reports it, then one frame. */ + tiles(loaded) { + tilesLoaded = loaded; + engine.fake.frame(); + }, + credits: () => engine.fake.credits.length, + inputs: () => inputs, + click(event) { + engine.fake.input('click', event); + }, + height: (value) => { + camera.height = value; + }, pick: (value) => { picked = value; }, @@ -355,8 +336,9 @@ function harness( test('construction validates source and does not acquire; double init throws', () => { assert.throws(() => createWildlifeSightingsLayer(), TypeError); const h = harness({ getSnapshot: () => assert.fail('enable fetched') }); - assert.throws(() => h.layer.init(h.viewer), /already initialized/); - assert.equal(h.clicks.collection.show, true); + assert.throws(() => h.init(), /already initialized/); + assert.equal(h.badgeSet(), undefined, 'no badge set before any badge'); + assert.deepEqual(h.engine.movers.list(), []); h.layer.destroy(); }); @@ -377,12 +359,11 @@ test('terrain warming resamples unchanged point, card and context anchors withou samples++; return height; }; - h.layer.init(h.viewer); - h.layer.enable(h.viewer); + h.init(); + h.layer.enable(); await h.update(); h.layer.selectSighting('inat:1'); - const point = h.clicks.collection.values[0]; - const sea = h.clicks.collection.values[1]; + const [point, sea] = h.badges(); const before = samples; height = 600; await h.update(); @@ -392,26 +373,22 @@ test('terrain warming resamples unchanged point, card and context anchors withou before, 'unchanged snapshots and camera movement do not resample per point', ); - h.viewer.scene.globe.tileLoadProgressEvent.emit(1); + h.tiles(false); assert.equal(samples, before, 'wait for terrain loading to settle'); - h.viewer.scene.globe.tileLoadProgressEvent.emit(0); - assert.ok( - Math.abs(Cesium.Cartographic.fromCartesian(point.position).height - 600) < - 0.01, - ); + h.tiles(true); + assert.ok(Math.abs(cartographicOf(point.position).height - 600) < 0.01); assert.equal( samples, before + 1, 'Acartia sea anchors are never terrain-sampled', ); - assert.ok( - Math.abs(Cesium.Cartographic.fromCartesian(sea.position).height) < 0.01, - ); + assert.ok(Math.abs(cartographicOf(sea.position).height) < 0.01); assert.equal( - h.clicks.collection.values[0], - point, - 'resampling preserves the point primitive', + h.badges()[0].id, + point.id, + 'resampling preserves the badge instance', ); + assert.equal(h.badges().length, 2); assert.deepEqual( h.overlay.entries.get('wildlife-sightings')[0].position, point.position, @@ -419,83 +396,61 @@ test('terrain warming resamples unchanged point, card and context anchors withou assert.ok( h.overlay.entries .get('wildlife-sightings-selected') - .every((entry) => - Cesium.Cartesian3.equals(entry.position, point.position), - ), + .every((entry) => geo.cartesian3.equals(entry.position, point.position)), ); assert.deepEqual( h.store.entities.get('inat:1').entity.__localBaseCartesian, point.position, ); assert.ok(h.renders.includes('wildlife-sightings-terrain')); - assert.equal(h.postRender.count(), 0); + assert.equal(h.frameWork(), 0); h.layer.disable(); - assert.equal(h.viewer.scene.globe.tileLoadProgressEvent.count(), 0); - assert.equal(h.viewer.scene.globe.terrainProviderChanged.count(), 0); + assert.equal(h.surfaceWatch(), 0); const disabledSamples = samples; - h.viewer.scene.globe.tileLoadProgressEvent.emit(0); + h.tiles(false); + h.tiles(true); assert.equal(samples, disabledSamples); height = 900; - h.layer.enable(h.viewer); + h.layer.enable(); assert.ok( - Math.abs( - Cesium.Cartographic.fromCartesian(h.clicks.collection.values[0].position) - .height - 900, - ) < 0.01, + Math.abs(cartographicOf(h.badges()[0].position).height - 900) < 0.01, ); - assert.equal(h.viewer.scene.globe.tileLoadProgressEvent.count(), 1); + assert.equal(h.surfaceWatch(), 1); h.layer.destroy(); - assert.equal(h.viewer.scene.globe.tileLoadProgressEvent.count(), 0); + assert.equal(h.surfaceWatch(), 0); }); -test('terrain provider, loaded tilesets and map-source changes refresh anchors and release listeners', async () => { +test('surface loading and map-source changes refresh anchors and release listeners', async () => { const h = harness(undefined, {}, { autoEnable: false }); - const tileset = { allTilesLoaded: event() }; - h.pointOwners.add(tileset); let height = 20; h.ground.sample = () => height; - h.layer.init(h.viewer); - h.layer.enable(h.viewer); + h.init(); + h.layer.enable(); await h.update(); - const point = h.clicks.collection.values[0]; + const point = h.badges()[0]; const assertHeight = (expected) => - assert.ok( - Math.abs( - Cesium.Cartographic.fromCartesian(point.position).height - expected, - ) < 0.01, - ); + assert.ok(Math.abs(heightOf(point.position) - expected) < 0.01); height = 200; - h.viewer.scene.globe.terrainProviderChanged.emit({}); + h.tiles(false); + assertHeight(20); + h.tiles(true); assertHeight(200); height = 400; - tileset.allTilesLoaded.emit(); + h.tiles(true); + assertHeight(200); + h.tiles(false); + h.tiles(true); assertHeight(400); - const nextTileset = { allTilesLoaded: event() }; - height = 600; - h.pointOwners.add(nextTileset); - assert.equal( - nextTileset.allTilesLoaded.count(), - 1, - 'new tilesets are subscribed without polling', - ); - nextTileset.allTilesLoaded.emit(); - assertHeight(600); height = 800; h.events.dispatchEvent(new Event('gev:map-stack-changed')); assertHeight(800); - height = 0; - h.pointOwners.remove(nextTileset); - assertHeight(0); - assert.equal(nextTileset.allTilesLoaded.count(), 0); h.layer.disable(); - assert.equal(tileset.allTilesLoaded.count(), 0); - assert.equal(h.pointOwners.primitiveAdded.count(), 0); - assert.equal(h.pointOwners.primitiveRemoved.count(), 0); + assert.equal(h.surfaceWatch(), 0); height = 1000; h.events.dispatchEvent(new Event('gev:map-stack-changed')); - assertHeight(0); + assert.equal(h.badges().length, 0); h.layer.destroy(); - assert.equal(h.postRender.count(), 0); + assert.equal(h.frameWork(), 0); }); test('camera rebuilds expire unselected sightings while a selected observation stays pinned', async () => { @@ -518,7 +473,7 @@ test('camera rebuilds expire unselected sightings while a selected observation s h.rectangle({ south: 47.4, west: -122.7, north: 47.9, east: -122.1 }); h.moveEnd.emit(); assert.equal( - h.clicks.collection.values[0].id, + h.badges()[0].id, 'wildlife:inat:1', 'the prior area is retained in the LRU', ); @@ -527,7 +482,7 @@ test('camera rebuilds expire unselected sightings while a selected observation s h.time(NOW + windowHours * 3_600_000); h.moveEnd.emit(); assert.equal( - h.clicks.collection.values.length, + h.badges().length, 1, `${taxa} keeps the explicitly selected observation beyond its window`, ); @@ -538,11 +493,7 @@ test('camera rebuilds expire unselected sightings while a selected observation s ); h.layer.clearSightingSelection(); h.moveEnd.emit(); - assert.equal( - h.clicks.collection.values.length, - 0, - 'expired rows retire on deselection', - ); + assert.equal(h.badges().length, 0, 'expired rows retire on deselection'); assert.equal(requests, 2, 'expiry needs no new source success'); h.layer.destroy(); } @@ -559,7 +510,7 @@ test('pending filter changes retain the accepted recency window until their resu h.layer.setParams({ taxa: 'all' }); assert.equal(h.layer.selectSighting('inat:1'), true); assert.equal( - h.clicks.collection.values.length, + h.badges().length, 1, 'three-day whale remains while ALL is pending', ); @@ -580,33 +531,26 @@ test('sampled ground and sea heights keep badges on top and update them in place ]; const h = harness({ getSnapshot: async () => rows }); await h.update(); - const first = h.clicks.collection.values[0]; - assert.equal(first.disableDepthTestDistance, Infinity); - assert.ok( - Math.abs(Cesium.Cartographic.fromCartesian(first.position).height - 123) < - 0.01, - ); - assert.ok( - Math.abs( - Cesium.Cartographic.fromCartesian(h.clicks.collection.values[1].position) - .height, - ) < 0.01, - ); + const first = h.badges()[0]; + assert.equal(h.badgeSet().options.depthTest, 'always'); + assert.ok(Math.abs(cartographicOf(first.position).height - 123) < 0.01); + assert.ok(Math.abs(cartographicOf(h.badges()[1].position).height) < 0.01); await h.update(); - assert.equal(h.clicks.collection.values[0], first); + assert.equal(h.badges()[0].id, first.id); for (const change of [ { lon: -122.3 }, { iconic: 'Fungi' }, { quality: 'needs_id' }, ]) { - const before = h.clicks.collection.values[0]; + const before = h.badgeSet().store.count; rows = [{ ...rows[0], ...change }]; await h.update(); assert.equal( - h.clicks.collection.values[0], - before, + h.badges()[0].id, + first.id, 'changed contacts update in place', ); + assert.ok(h.badgeSet().store.count <= before); } assert.ok(h.renders.length > 0); h.layer.destroy(); @@ -614,11 +558,7 @@ test('sampled ground and sea heights keep badges on top and update them in place unsupported.ground.sample = () => null; await unsupported.update(); assert.ok( - Math.abs( - Cesium.Cartographic.fromCartesian( - unsupported.clicks.collection.values[0].position, - ).height, - ) < 0.01, + Math.abs(cartographicOf(unsupported.badges()[0].position).height) < 0.01, ); unsupported.layer.destroy(); }); @@ -627,7 +567,7 @@ test('a painted card hides its anchored badge across rebuilds while retaining it const h = harness({ getSnapshot: async () => [photographed] }); t.after(() => h.layer.destroy()); await h.update(); - const point = h.clicks.collection.values[0]; + const point = h.badges()[0]; assert.equal(point.show, true); const visibility = h.overlay.options.get('wildlife-sightings').onVisibilityChange; @@ -687,9 +627,9 @@ test('dot, ambient card and context clicks focus the true sighting; cards win ov ); await h.update(); h.pick({ id: 'wildlife:inat:1' }); - h.clicks.callback({ position: { x: 20, y: 30 } }); + h.click({ position: { x: 20, y: 30 } }); assert.equal(h.focus.length, 1); - assert.equal(h.clicks.collection.values[0].scale, 1); + assert.equal(h.badges()[0].scale, 1); assert.equal(h.store.selectedEntityId, 'inat:1'); assert.deepEqual(selections.at(-1), { row: photographed, @@ -720,7 +660,7 @@ test('dot, ambient card and context clicks focus the true sighting; cards win ov ); h.hit({ sourceId: 'wildlife-sightings', entry: ambient }); h.pick({ id: 'other:aircraft' }); - h.clicks.callback({ position: {} }); + h.click({ position: {} }); assert.deepEqual(h.links, [], 'canvas cards select; they never open links'); assert.equal(h.focus.length, 3); assert.equal( @@ -729,11 +669,11 @@ test('dot, ambient card and context clicks focus the true sighting; cards win ov 'ambient cards win over scene picks', ); h.hit(null); - h.clicks.callback({ position: {} }); + h.click({ position: {} }); assert.equal(h.store.selectedEntityId, 'inat:1', 'sibling picks yield'); h.free(false); h.pick(null); - h.clicks.callback({ position: {} }); + h.click({ position: {} }); assert.equal(h.store.selectedEntityId, 'inat:1'); h.contextSelect('inat:1'); assert.equal( @@ -743,7 +683,7 @@ test('dot, ambient card and context clicks focus the true sighting; cards win ov ); assert.deepEqual(selections.at(-1).metadata, { explicitSelection: true }); h.free(true); - h.clicks.callback({ position: {} }); + h.click({ position: {} }); assert.equal(h.store.selectedEntityId, null); h.layer.disable(); assert.equal(ambient.activate(), false); @@ -808,14 +748,8 @@ test('selection subscriptions publish current rows, accepted refreshes, reselect assert.equal(seen.at(-1), null); assert.deepEqual(metadata.at(-1), { explicitSelection: false }); assert.equal(h.store.selectedEntityId, null); - assert.equal(h.clicks.collection.values[0].scale, 1); - assert.equal( - Cesium.Color.equals( - h.clicks.collection.values[0].color, - Cesium.Color.WHITE, - ), - true, - ); + assert.equal(h.badges()[0].scale, 1); + assert.deepEqual(h.badges()[0].color, [1, 1, 1, 1]); assert.deepEqual(h.overlay.entries.get('wildlife-sightings-selected'), []); assert.equal(h.layer.clearSightingSelection(), false); h.layer.selectSighting('inat:1'); @@ -867,7 +801,7 @@ test('selection subscriptions retain omitted rows and clear on disable and destr assert.equal(seen.at(-1), null); assert.equal(eventListeners.get('gev:entity-selected').size, 0); const disabledCount = seen.length; - h.layer.enable(h.viewer); + h.layer.enable(); assert.equal(eventListeners.get('gev:entity-selected').size, 1); await h.update(); h.layer.selectSighting('inat:1'); @@ -878,7 +812,7 @@ test('selection subscriptions retain omitted rows and clear on disable and destr ); h.layer.destroy(); assert.equal(seen.at(-1), null); - assert.equal(h.clicks.callback, null); + assert.equal(h.inputs(), 0, 'no click listener remains'); assert.equal(eventListeners.get('gev:entity-selected').size, 0); const destroyedCount = seen.length; h.layer.clearSightingSelection(); @@ -902,7 +836,7 @@ test('selection subscriptions retain omitted rows and clear on disable and destr unsubscribe(); }); -test('privacy cells stage nonempty batches, request frames until ready, supersede and cancel cleanly', async () => { +test('privacy cells draw outlines on the engine, replace changed batches and release them', async () => { let rows = [ { ...base, @@ -912,36 +846,28 @@ test('privacy cells stage nonempty batches, request frames until ready, supersed ]; const h = harness({ getSnapshot: async () => rows }); await h.update(); - assert.equal(h.groundOwners.values.length, 2); - assert.equal(h.postRender.count(), 1); - const pending = [...h.groundOwners.values]; - h.postRender.emit(); - assert.equal(h.renders.at(-1), 'wildlife-sightings-staging'); + const first = h.engine.lines.list(); + assert.equal(first.length, 1); + assert.equal(h.frameWork(), 0, 'outlines need no frame work'); rows = [{ ...rows[0], iconic: 'Fungi' }]; await h.update(); - assert.ok(pending.every((primitive) => primitive.destroyed)); - assert.equal(h.groundOwners.values.length, 2); - for (const primitive of h.groundOwners.values) primitive.ready = true; - h.postRender.emit(); - assert.ok(h.groundOwners.values.every((primitive) => primitive.show)); - assert.equal(h.postRender.count(), 0); + const second = h.engine.lines.list(); + assert.equal(second.length, 1); + assert.notEqual(second[0].id, first[0].id, 'a new accent replaces the line'); + assert.equal(h.engine.fake.line(first[0].id), undefined); h.layer.selectSighting(base.stableId); assert.equal(h.focus.at(-1).kind, 'sighting-area'); - const selectedRing = h.clicks.collection.values[0]; - assert.equal( - Cesium.Color.equals(selectedRing.color, Cesium.Color.WHITE), - true, - ); + const selectedRing = h.badges()[0]; + assert.deepEqual(selectedRing.color, [1, 1, 1, 1]); assert.equal(selectedRing.scale, 1); - assert.match(selectedRing.image, /^data:image\/svg/); - assert.equal( - h.postRender.count(), - 0, + assert.match(h.badgeSet().options.sprite.atlas.url, /^data:image\/svg/); + assert.deepEqual( + h.engine.lines.list(), + second, 'selection reuses unchanged privacy geometry', ); h.layer.disable(); - assert.equal(h.postRender.count(), 0); - assert.equal(h.groundOwners.values.length, 0); + assert.deepEqual(h.engine.lines.list(), []); h.layer.destroy(); }); @@ -964,15 +890,16 @@ test('disable ignores late successes and failures, unregisters owners and hides settle(reject ? Error('iNaturalist HTTP 502') : [photographed]); assert.equal(await pending, false); assert.equal(h.layer.getStats().error, null); - assert.equal(h.clicks.collection.values.length, 0); + assert.equal(h.badges().length, 0); assert.equal(h.owners.size, 0); - assert.equal(h.credits.size, 0); + assert.equal(h.credits(), 0); assert.deepEqual([...h.overlay.visible.values()], [false, false, false]); assert.equal(h.overlay.entries.size, 0); assert.equal(h.moveEnd.count(), 0); h.layer.destroy(); - assert.equal(h.pointOwners.values.length, 0); - assert.equal(h.clicks.unregistered, true); + assert.deepEqual(h.engine.movers.list(), []); + assert.equal(h.badgeSet(), undefined); + assert.equal(h.inputs(), 0); } }); @@ -1135,7 +1062,7 @@ test('the real manager keeps first enable on after a pan supersedes either a suc {}, { autoEnable: false }, ); - const manager = new LayerLifecycle(h.viewer); + const manager = new LayerLifecycle(null, { engine: h.engine }); manager.register(h.layer); h.layer.attachDataManager(manager); const fireCameraTimer = () => { @@ -1239,7 +1166,7 @@ test('first enable settles once and publishes badges within two seconds without {}, { autoEnable: false }, ); - const manager = new LayerLifecycle(h.viewer); + const manager = new LayerLifecycle(null, { engine: h.engine }); manager.register(h.layer); h.layer.attachDataManager(manager); try { @@ -1262,7 +1189,7 @@ test('first enable settles once and publishes badges within two seconds without h.time(NOW + 550); // Ordinary upstream response after 150 ms. finishObservations([row]); assert.equal(await enabling, true); - assert.equal(h.clicks.collection.values[0]?.id, 'wildlife:inat:1'); + assert.equal(h.badges()[0]?.id, 'wildlife:inat:1'); assert.equal(requests.length, 1); assert.equal(gridSets, 1); assert.equal( @@ -1294,7 +1221,7 @@ test('the real manager does not report a refresh error when a pan supersedes per {}, { autoEnable: false }, ); - const manager = new LayerLifecycle(h.viewer); + const manager = new LayerLifecycle(null, { engine: h.engine }); manager.register(h.layer); h.layer.attachDataManager(manager); try { @@ -1491,7 +1418,7 @@ test('global fallback hides cards and upstream totals, and caps retained observa })); }, }); - h.viewer.camera.positionCartographic.height = 5_000_000; + h.height(5_000_000); await h.update(); assert.equal(options.scope, 'global'); assert.equal(options.boxes, null); @@ -1521,11 +1448,11 @@ test('a successful empty page keeps the selected observation until explicit dese h.layer.selectSighting(base.stableId); rows = []; await h.update(); - assert.equal(h.clicks.collection.values.length, 1); + assert.equal(h.badges().length, 1); assert.equal(h.store.selectedEntityId, base.stableId); h.layer.clearSightingSelection(); await h.update(); - assert.equal(h.clicks.collection.values.length, 0); + assert.equal(h.badges().length, 0); assert.equal(h.store.selectedEntityId, null); h.layer.destroy(); }); @@ -1548,7 +1475,8 @@ test('provider credits sit in the layer row, never on the globe; Acartia failure }); await h.update(); const onGlobe = () => - [...h.credits].filter((credit) => credit.showOnScreen).length; + h.engine.fake.credits.filter((credit) => credit.onScreen).length; + assert.equal(h.credits(), 1, 'the Acartia credit joins the attribution'); assert.equal(onGlobe(), 0, 'no credit on the globe credit line'); assert.equal( h.layer.getRowControls().info, @@ -1652,19 +1580,14 @@ test('ambient cards prefilter the horizon, spread over screen regions and retain { occluderFactory: () => ({ isPointVisible: (position) => - Cesium.Math.toDegrees( - Cesium.Cartographic.fromCartesian(position).longitude, - ) > -122.55, + toDegrees(cartographicOf(position).longitude) > -122.55, }), project: (_scene, position) => screens.get( rows.find( (row) => Math.abs( - row.lon - - Cesium.Math.toDegrees( - Cesium.Cartographic.fromCartesian(position).longitude, - ), + row.lon - toDegrees(cartographicOf(position).longitude), ) < 1e-7, )?.stableId, ), @@ -1698,11 +1621,9 @@ test('ambient cards prefilter the horizon, spread over screen regions and retain } }; assertCohort(entries); - const farDot = h.clicks.collection.values.find( - (point) => point.id === 'wildlife:inat:far', - ); + const farDot = h.badges().find((point) => point.id === 'wildlife:inat:far'); assert.ok(!farDot || farDot.show === false); - assert.equal(h.postRender.count(), 0); + assert.equal(h.frameWork(), 0); h.layer.selectSighting('inat:01'); const selected = h.overlay.entries.get('wildlife-sightings-selected'); h.moveEnd.emit(); @@ -1744,9 +1665,9 @@ test('observations publish real badges while grid acquisition is still pending', const updating = h.layer.update(); try { await new Promise((resolve) => setImmediate(resolve)); - assert.equal(h.clicks.collection.values[0]?.id, 'wildlife:inat:1'); + assert.equal(h.badges()[0]?.id, 'wildlife:inat:1'); assert.equal(h.layer.getStats().count, 1); - assert.equal(h.postRender.count(), 0, 'acquisition adds no frame work'); + assert.equal(h.frameWork(), 0, 'acquisition adds no frame work'); } finally { finishGrid({ cells: [], totalResults: 0, fetchedAt: NOW }); await updating; @@ -1787,21 +1708,19 @@ test('each arriving tile extends the density glow before the remaining tiles fin { tile: tiles[0] }, ); await new Promise((resolve) => setImmediate(resolve)); - assert.equal(h.clicks.collection.values.length, 0); - const glow = h.pointOwners.values.find( - (collection) => collection.allowPicking === false, - ); - assert.equal(glow.values.length, 1); + assert.equal(h.badges().length, 0); + assert.equal(h.glow().options.pickable, false); + assert.equal(h.glow().store.count, 1); assert.equal(h.layer.getStats().countLabel, '12 sightings in view'); publishTile?.( { cells: [cells[1]], totalResults: 9, fetchedAt: NOW }, { tile: tiles[1] || tiles[0] }, ); await new Promise((resolve) => setImmediate(resolve)); - assert.equal(h.clicks.collection.values.length, 0); - assert.equal(glow.values.length, 2); + assert.equal(h.badges().length, 0); + assert.equal(h.glow().store.count, 2); assert.equal(h.layer.getStats().countLabel, '21 sightings in view'); - assert.equal(h.postRender.count(), 0); + assert.equal(h.frameWork(), 1, 'only the glow follows the camera'); } finally { finishGrid({ cells, totalResults: 21, fetchedAt: NOW }); await updating; @@ -1819,37 +1738,34 @@ test('grid-only views draw nonpickable glow without observation badges, labels o fetchedAt: NOW, }), }); - h.viewer.camera.positionCartographic.height = 1_500_000; + h.height(1_500_000); await h.update(); - assert.equal(h.clicks.collection.values.length, 0); - const glow = h.pointOwners.values.find( - (collection) => collection.allowPicking === false, - ); + assert.equal(h.badges().length, 0); + const glow = h.glow(); assert.ok(glow); - assert.equal(glow.values.length, 1); - assert.equal(glow.values[0].id, undefined, 'density has no pick identity'); + assert.equal(glow.options.pickable, false); + assert.equal(glow.store.count, 1); + assert.equal( + glow.store.pickIds[0], + undefined, + 'density has no pick identity', + ); assert.equal(h.layer.getStats().countLabel, '12 sightings in view'); assert.deepEqual(h.overlay.entries.get('wildlife-sightings-labels'), []); assert.deepEqual(h.overlay.entries.get('wildlife-sightings-selected'), []); assert.equal(h.focus.length, 0); - assert.equal(h.postRender.count(), 0); + assert.equal(h.frameWork(), 1, 'only the glow follows the camera'); h.layer.disable(); - assert.equal(glow.destroyed, true); + assert.equal(h.glow(), undefined); + assert.equal(h.frameWork(), 0); assert.equal(h.timers.size, 0); - assert.equal( - h.pointOwners.values.length, - 1, - 'only the empty observation collection remains', - ); - h.layer.enable(h.viewer); + assert.deepEqual(h.engine.movers.list(), [], 'no glow remains'); + h.layer.enable(); await h.update(); - const restored = h.pointOwners.values.find( - (collection) => collection.allowPicking === false, - ); - assert.ok(restored); + assert.ok(h.glow()); h.layer.destroy(); - assert.equal(restored.destroyed, true); - assert.equal(h.pointOwners.values.length, 0); + assert.equal(h.glow(), undefined); + assert.deepEqual(h.engine.movers.list(), []); assert.equal(h.timers.size, 0); }); @@ -1873,20 +1789,20 @@ test('height and upstream sample totals retain the same loaded sighting and grid }, }); await h.update(); - assert.equal(h.clicks.collection.values[0].id, 'wildlife:inat:1'); - h.viewer.camera.positionCartographic.height = 450_000; + assert.equal(h.badges()[0].id, 'wildlife:inat:1'); + h.height(450_000); await h.update(); - assert.equal(h.clicks.collection.values[0].id, 'wildlife:inat:1'); - h.viewer.camera.positionCartographic.height = 410_000; + assert.equal(h.badges()[0].id, 'wildlife:inat:1'); + h.height(410_000); h.moveEnd.emit(); - assert.equal(h.clicks.collection.values[0].id, 'wildlife:inat:1'); - h.viewer.camera.positionCartographic.height = 350_000; + assert.equal(h.badges()[0].id, 'wildlife:inat:1'); + h.height(350_000); await h.update(); - assert.equal(h.clicks.collection.values[0].id, 'wildlife:inat:1'); - h.viewer.camera.positionCartographic.height = 30_000; + assert.equal(h.badges()[0].id, 'wildlife:inat:1'); + h.height(30_000); partialSample = true; await h.update(); - assert.equal(h.clicks.collection.values[0].id, 'wildlife:inat:1'); + assert.equal(h.badges()[0].id, 'wildlife:inat:1'); assert.equal(gridRequests, 4); assert.equal(h.layer.getStats().countLabel, '12 sightings in view'); assert.equal(layerFeedState(h.layer.getStats()), 'nominal'); @@ -1913,9 +1829,9 @@ test('close and wide views wait for loaded common-name labels without unnamed la }, ); t.after(() => h.layer.destroy()); - h.viewer.camera.positionCartographic.height = heightM; + h.height(heightM); await h.update(); - const point = h.clicks.collection.values[0]; + const point = h.badges()[0]; assert.ok(point, `${heightM}m: unnamed contacts still have icons`); assert.deepEqual(h.overlay.entries.get('wildlife-sightings-labels'), []); // Only observation icons require screen projections; unnamed contacts @@ -1925,13 +1841,12 @@ test('close and wide views wait for loaded common-name labels without unnamed la 1, 'only the real observation is projected; unnamed rows add no label pass', ); - assert.equal(h.postRender.count(), 0); rows = [{ ...base, commonName: 'Red-tailed Hawk' }]; await h.update(); assert.equal( - h.clicks.collection.values[0], - point, + h.badges()[0].id, + point.id, `${heightM}m: a newly known name reuses the icon`, ); const labels = h.overlay.entries.get('wildlife-sightings-labels'); @@ -1941,7 +1856,7 @@ test('close and wide views wait for loaded common-name labels without unnamed la assert.equal(labels[0].protected, undefined); assert.equal(labels[0].selected, undefined); const settled = projections; - for (let frame = 0; frame < 10; frame++) h.postRender.emit(); + h.engine.fake.frames(10); assert.equal( projections, settled, @@ -1963,8 +1878,8 @@ test('selecting an unnamed observation preserves the selection without inventing const selections = []; h.layer.subscribeSelection((row) => selections.push(row)); await h.update(); - h.pick({ id: h.clicks.collection.values[0].id }); - h.clicks.callback({ position: { x: 400, y: 400 } }); + h.pick({ id: h.badges()[0].id }); + h.click({ position: { x: 400, y: 400 } }); assert.equal(selections.at(-1).stableId, base.stableId); assert.deepEqual(h.overlay.entries.get('wildlife-sightings-selected'), []); assert.deepEqual(h.overlay.entries.get('wildlife-sightings-labels'), []); @@ -1982,10 +1897,10 @@ test('unavailable grids fail closed to visible sampled sprites without a global throw Error('Malformed iNaturalist grid response'); }, }); - h.viewer.camera.positionCartographic.height = 5_000_000; + h.height(5_000_000); await h.update(); - assert.equal(h.clicks.collection.values[0].id, 'wildlife:inat:1'); - assert.ok(h.clicks.collection.values[0].width * 0.5 >= 8); + assert.equal(h.badges()[0].id, 'wildlife:inat:1'); + assert.ok(h.badgeSet().options.sprite.sizePx * 0.5 >= 8); assert.equal(h.layer.getStats().count, 1); assert.match(h.layer.getStats().countLabel, /sightings in view/); assert.equal(h.layer.getStats().partial, undefined); @@ -2020,10 +1935,10 @@ test('an oversized view coarsens to a complete grid within the six-tile cap', as { width: 4000, height: 4000 }, ); h.rectangle({ south: -85, west: -180, north: 85, east: 180 }); - h.viewer.camera.positionCartographic.height = 7_000_000; + h.height(7_000_000); await h.update(); - assert.equal(h.clicks.collection.values.length, 1); - assert.equal(h.clicks.collection.values[0].id, 'wildlife:inat:1'); + assert.equal(h.badges().length, 1); + assert.equal(h.badges()[0].id, 'wildlife:inat:1'); assert.match(h.layer.getStats().countLabel, /sightings in view/); assert.equal(layerFeedState(h.layer.getStats()), 'nominal'); h.layer.destroy(); @@ -2042,16 +1957,11 @@ test('label candidates are bounded after on-screen and horizon filtering, with n { project: (_scene, position) => ({ x: - Cesium.Cartographic.fromCartesian(position).latitude > - Cesium.Math.toRadians(47.6) + cartographicOf(position).latitude > toRadians(47.6) ? 3000 : 30 + (Math.round( - (Cesium.Math.toDegrees( - Cesium.Cartographic.fromCartesian(position).latitude, - ) - - 47.5) * - 2000, + (toDegrees(cartographicOf(position).latitude) - 47.5) * 2000, ) % 50) * 28, @@ -2059,19 +1969,14 @@ test('label candidates are bounded after on-screen and horizon filtering, with n 30 + Math.floor( Math.round( - (Cesium.Math.toDegrees( - Cesium.Cartographic.fromCartesian(position).latitude, - ) - - 47.5) * - 2000, + (toDegrees(cartographicOf(position).latitude) - 47.5) * 2000, ) / 50, ) * 28, }), occluderFactory: () => ({ isPointVisible: (position) => - Cesium.Cartographic.fromCartesian(position).latitude < - Cesium.Math.toRadians(47.59), + cartographicOf(position).latitude < toRadians(47.59), }), }, ); @@ -2087,12 +1992,10 @@ test('label candidates are bounded after on-screen and horizon filtering, with n ); assert.ok( labels.every( - (entry) => - Cesium.Cartographic.fromCartesian(entry.position).latitude < - Cesium.Math.toRadians(47.59), + (entry) => cartographicOf(entry.position).latitude < toRadians(47.59), ), ); - assert.equal(h.postRender.count(), 0); + assert.equal(h.frameWork(), 0); h.layer.destroy(); }); @@ -2100,22 +2003,20 @@ test('accepted observation refreshes publish immediately and disable clears thei let rows = [base]; const h = harness({ getSnapshot: async () => rows }); await h.update(); - const point = h.clicks.collection.values[0]; + const point = h.badges()[0]; for (const lon of [-122.35, -122.3, -122.25]) { rows = [{ ...base, lon }]; await h.layer.update(); - assert.equal(h.clicks.collection.values[0], point); + assert.equal(h.badges()[0].id, point.id); assert.ok( - Math.abs( - Cesium.Cartographic.fromCartesian(point.position).longitude - - Cesium.Math.toRadians(lon), - ) < 1e-12, + Math.abs(cartographicOf(point.position).longitude - toRadians(lon)) < + 1e-12, ); assert.equal(h.timers.size, 0, 'observation rendering adds no batch delay'); } h.layer.disable(); assert.equal(h.timers.size, 0); - assert.equal(h.clicks.collection.values.length, 0); + assert.equal(h.badges().length, 0); h.layer.destroy(); }); @@ -2136,7 +2037,7 @@ test('a grid result for the previous global view cannot mark new camera tiles lo }); }, }); - h.viewer.camera.positionCartographic.height = 5_000_000; + h.height(5_000_000); h.time(NOW + 500); const updating = h.layer.update(); await new Promise((resolve) => setImmediate(resolve)); @@ -2144,9 +2045,7 @@ test('a grid result for the previous global view cannot mark new camera tiles lo settle(); await updating; assert.equal( - h.clicks.collection.values.filter((point) => - point.id.startsWith('wildlife:grid:'), - ).length, + h.badges().filter((point) => point.id.startsWith('wildlife:grid:')).length, 0, 'old tile result is rejected before moveEnd', ); @@ -2169,7 +2068,7 @@ test('clearing a selected contact restores its cached ambient label without proj assert.equal(h.overlay.entries.get('wildlife-sightings-labels').length, 1); h.pick({ id: 'wildlife:inat:1' }); const renders = h.renders.length; - h.clicks.callback({ position: { x: 1, y: 1 } }); + h.click({ position: { x: 1, y: 1 } }); assert.equal(h.renders.length - renders, 1); assert.equal(h.overlay.entries.get('wildlife-sightings-labels').length, 0); assert.equal(h.overlay.entries.get('wildlife-sightings-selected').length, 1); @@ -2191,10 +2090,7 @@ test('visible photo cards survive a pan into the same icon bucket and a differen { project: (_scene, position) => ({ x: - collapsed || - Cesium.Math.toDegrees( - Cesium.Cartographic.fromCartesian(position).longitude, - ) < -122.3 + collapsed || toDegrees(cartographicOf(position).longitude) < -122.3 ? 400 : 700, y: 400, @@ -2212,11 +2108,7 @@ test('visible photo cards survive a pan into the same icon bucket and a differen assert.equal(initial.length, 2); collapsed = true; h.moveEnd.emit(); - assert.equal( - h.clicks.collection.values.length, - 1, - 'one icon in the shared bucket', - ); + assert.equal(h.badges().length, 1, 'one icon in the shared bucket'); assert.deepEqual( ids(), initial, @@ -2245,21 +2137,140 @@ test('cold ground cells start on the ellipsoid and one bounded resolve per batch reads++; return warm ? 250 : null; }; - h.layer.init(h.viewer); - h.layer.enable(h.viewer); + h.init(); + h.layer.enable(); await h.update(); - const point = h.clicks.collection.values[0]; - const heightOf = () => - Cesium.Cartographic.fromCartesian(point.position).height; - assert.ok(Math.abs(heightOf()) < 0.01, 'cold cell anchors on the ellipsoid'); + const point = h.badges()[0]; + const pointHeight = () => heightOf(point.position); + assert.ok( + Math.abs(pointHeight()) < 0.01, + 'cold cell anchors on the ellipsoid', + ); assert.equal(h.ground.resolves, 1, 'one bounded resolve for the batch'); warm = true; await new Promise((resolve) => setImmediate(resolve)); - assert.ok(Math.abs(heightOf() - 250) < 0.01, 'resolved cell lifts in place'); - assert.equal(h.clicks.collection.values[0], point); + assert.ok( + Math.abs(pointHeight() - 250) < 0.01, + 'resolved cell lifts in place', + ); + assert.equal(h.badges()[0].id, point.id); const before = reads; h.moveEnd.emit(); - assert.ok(reads - before <= h.clicks.collection.values.length); + assert.ok(reads - before <= h.badges().length); assert.equal(h.ground.resolves, 1, 'warm cells do not resolve again'); h.layer.destroy(); }); + +test('an engine switch keeps the data and selection and redraws the same badges and glow on the next engine', async () => { + let requests = 0; + const rows = [ + photographed, + { ...base, stableId: 'inat:2', lon: -122.3, iconic: 'Mammalia' }, + { + ...base, + stableId: 'inat:3', + lon: -122.2, + obscured: true, + cell: { south: 47.5, west: -122.3, north: 47.7, east: -122.1 }, + }, + ]; + const h = harness({ + getSnapshot: async () => { + requests++; + return rows; + }, + getGridSnapshot: async ({ tiles }) => ({ + cells: [gridCell(tiles[0].z)], + totalResults: 12, + fetchedAt: NOW, + }), + }); + await h.update(); + assert.equal(h.layer.selectSighting('inat:1'), true); + const drawn = (engine) => + h.badges(engine).map((badge) => ({ + id: badge.id, + position: badge.position, + cell: badge.cell, + show: badge.show, + scale: badge.scale, + color: badge.color, + })); + const style = (engine) => { + const { options } = h.badgeSet(engine); + return { + order: options.order, + depthTest: options.depthTest, + pickable: options.pickable, + sizePx: options.sprite.sizePx, + scaleByDistance: [...options.sprite.scaleByDistance], + cells: options.sprite.atlas.cells, + }; + }; + const glow = (engine) => { + const set = engine.fake.moverSet(WILDLIFE_DENSITY_SET_ID); + return { + count: set.store.count, + pickable: set.options.pickable, + positions: [...set.store.positions.subarray(0, set.store.count * 3)], + }; + }; + const first = h.engine; + const before = drawn(first); + assert.equal(before.length, 3); + const beforeStyle = style(first); + const beforeGlow = glow(first); + assert.equal(beforeGlow.count, 1); + const outline = first.fake.line(first.lines.list()[0].id); + const stats = h.layer.getStats(); + + h.layer.detachRenderer(); + assert.deepEqual(first.movers.list(), [], 'nothing stays drawn'); + assert.deepEqual(first.lines.list(), []); + assert.equal(first.camera.moveEnd.numberOfListeners, 0); + assert.equal(first.frames.preRender.numberOfListeners, 0); + assert.equal(first.frames.postRender.numberOfListeners, 0); + assert.equal(h.inputs(), 0, 'no click listener on the old engine'); + assert.equal(h.owners.size, 0); + assert.equal(h.overlay.visible.get('wildlife-sightings-selected'), false); + + const other = ENGINE_KINDS.find((kind) => kind !== h.engine.kind); + const next = h.globe(other); + assert.equal(h.layer.attachRenderer(null, { engine: next }), true); + h.flushIdle(); + assert.deepEqual(drawn(next), before, 'the same badges, drawn the same'); + assert.deepEqual(style(next), beforeStyle); + assert.deepEqual(glow(next), beforeGlow); + const redrawn = next.fake.line(next.lines.list()[0].id); + assert.deepEqual(redrawn.options.color, outline.options.color); + assert.deepEqual([...redrawn.positions], [...outline.positions]); + assert.equal(requests, 1, 'the retained view needs no new request'); + assert.equal(h.layer.getStats().count, stats.count); + assert.equal( + h.overlay.entries.get('wildlife-sightings-selected')[0].id, + 'wildlife-sightings-label:inat:1', + 'the selection carries over', + ); + assert.equal(h.overlay.visible.get('wildlife-sightings-selected'), true); + assert.equal(h.owners.size, 1); + assert.equal(h.inputs(), 1); + + h.pick({ id: 'wildlife:inat:2' }); + next.fake.input('click', { position: { x: 1, y: 1 } }); + assert.equal( + h.store.selectedEntityId, + 'inat:2', + 'clicks reach the new engine', + ); + // The camera debounce is the one timer of 600 ms or more. + const debounces = () => + [...h.timers.values()].filter(({ delay }) => delay >= 600).length; + h.rectangle({ south: 48, west: -123, north: 48.4, east: -122.6 }); + first.fake.emit('camera.moveEnd'); + assert.equal(debounces(), 0, 'the old camera is no longer followed'); + next.fake.emit('camera.moveEnd'); + assert.equal(debounces(), 1, 'the new camera schedules acquisition'); + h.layer.destroy(); + assert.deepEqual(next.movers.list(), []); + assert.deepEqual(next.lines.list(), []); +}); diff --git a/src/layers/wildlifeSightings/policy.js b/src/layers/wildlifeSightings/policy.js index f1b6df277..befbecace 100644 --- a/src/layers/wildlifeSightings/policy.js +++ b/src/layers/wildlifeSightings/policy.js @@ -44,9 +44,6 @@ export const WILDLIFE_RENDER_LIMIT = 5_000; // Bound candidates before the overlay host's final declutter. export const WILDLIFE_LABEL_LIMIT = 150; export const WILDLIFE_LABEL_NAME_SEPARATION_PX = 80; -// Hidden ground batches may need several render passes to compile. Retire a -// failed staging batch after a bounded number instead of holding rendering. -export const WILDLIFE_GROUND_READY_MAX_CHECKS = 16; // Camera-height thresholds use +/-10% hysteresis to keep representations still // while a camera settles near a boundary. diff --git a/src/layers/wildlifeSightings/render.perf.test.mjs b/src/layers/wildlifeSightings/render.perf.test.mjs index fe3357ce6..6917acc5b 100644 --- a/src/layers/wildlifeSightings/render.perf.test.mjs +++ b/src/layers/wildlifeSightings/render.perf.test.mjs @@ -32,11 +32,11 @@ function activeMs(slice, collections) { } test( - 'dense continental production moveEnd pipeline yields every 4ms, including cold tiles and real Cesium billboard updates', + 'dense continental production moveEnd pipeline yields every 4ms, including cold tiles and real engine sprite updates', perfBudgetTestOptions(), async (t) => { // Settle CPU-triggered runtime startup before creating the cold layer; - // cells, coordinates and billboard collections remain unwarmed. + // cells, coordinates and mover sets remain unwarmed. const calibrationStart = performance.now(); let calibrationChecksum = 0; for (;;) { diff --git a/src/layers/wildlifeSightings/renderPerformanceFixtures.mjs b/src/layers/wildlifeSightings/renderPerformanceFixtures.mjs index 30ef52361..1f829ebb3 100644 --- a/src/layers/wildlifeSightings/renderPerformanceFixtures.mjs +++ b/src/layers/wildlifeSightings/renderPerformanceFixtures.mjs @@ -1,13 +1,15 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { createRendering } from './rendering.js'; import { createSelection } from './selection.js'; +import { WILDLIFE_ATLAS_CELLS } from './sprites.js'; export const DENSE_CONTINENT_CELLS = 6 * 32 * 32; export const DENSE_CONTINENT_ROWS = 500; const NOW = Date.parse('2026-11-04T18:00:00Z'); -/** The real render + selection pipeline with Cesium coordinates/billboards. - * Only the WebGL scene and external overlay host are replaced. Six fully +/** The real render + selection pipeline on the engine contract's mover sets + * (a fake engine). Only the drawing and external overlay host are replaced. Six fully * occupied logical tiles deliberately include off-window cells and collisions. */ export function denseContinentalRendering({ onOperations } = {}) { const cells = []; @@ -41,23 +43,15 @@ export function denseContinentalRendering({ onOperations } = {}) { obscured: false, cell: null, })); - const primitives = { - add: (value) => value, - remove(value) { - value.destroy(); - return true; - }, - }; - const scene = { - canvas: { clientWidth: 1440, clientHeight: 900 }, - primitives, - }; + const engine = createFakeEngine({ width: 1440, height: 900 }); + // High above the continent, so every kernel is in front of the limb; the + // opacity reads a regional 2,000 km camera height. + engine.fake.setPose({ position: geo.cartesian3.fromDegrees(-112, 42, 2e7) }); + engine.camera.height = () => 2_000_000; + engine.projector.horizonVisible = () => true; const state = { enabled: true, - viewer: { - scene, - camera: { positionWC: {}, positionCartographic: { height: 2_000_000 } }, - }, + engine, rows: new Map(rows.map((row) => [row.stableId, row])), gridCells: cells, gridAvailable: true, @@ -77,11 +71,15 @@ export function denseContinentalRendering({ onOperations } = {}) { carriers: new Map(), contextIds: new Set(), }; - const centre = Cesium.Cartesian3.fromDegrees(-90, 42); - const frame = Cesium.Transforms.eastNorthUpToFixedFrame(centre); - const east = Cesium.Matrix4.getColumn(frame, 0, new Cesium.Cartesian4()); - const north = Cesium.Matrix4.getColumn(frame, 1, new Cesium.Cartesian4()); + const centre = geo.cartesian3.fromDegrees(-90, 42); + const frame = geo.transforms.eastNorthUpToFixedFrame(centre); + const east = { x: frame[0], y: frame[1], z: frame[2] }; + const north = { x: frame[4], y: frame[5], z: frame[6] }; const metrics = { projections: 0, publications: 0, cartesianBuilds: 0 }; + const atlas = { + url: 'data:image/svg+xml,badges', + cells: WILDLIFE_ATLAS_CELLS, + }; let pan = 0; let nextIdle = 0; const idle = new Map(); @@ -106,30 +104,8 @@ export function denseContinentalRendering({ onOperations } = {}) { performanceSlice(slice) { slices.push(slice); }, - cesium: { - ...Cesium, - Cartesian3: class extends Cesium.Cartesian3 { - static fromDegrees(...args) { - metrics.cartesianBuilds++; - return Cesium.Cartesian3.fromDegrees(...args); - } - }, - }, - project(_scene, anchor) { - metrics.projections++; - return { - x: - 720 + - (anchor.x * east.x + anchor.y * east.y + anchor.z * east.z) / 8000 + - pan, - y: - 450 - - (anchor.x * north.x + anchor.y * north.y + anchor.z * north.z) / 8000, - }; - }, - occluderFactory: () => ({ isPointVisible: () => true }), badgeSprites: { - get: (group) => `data:image/svg+xml,${group}`, + atlas: () => atlas, subscribe: () => () => {}, }, render: { governorRequestRender() {} }, @@ -138,8 +114,23 @@ export function denseContinentalRendering({ onOperations } = {}) { metrics.publications++; }, }, - performanceOperations: onOperations, + performanceOperations(operations) { + metrics.cartesianBuilds += operations.positionBuilds; + onOperations?.(operations); + }, }; + engine.fake.script('worldToWindow', (anchor) => { + metrics.projections++; + return { + x: + 720 + + (anchor.x * east.x + anchor.y * east.y + anchor.z * east.z) / 8000 + + pan, + y: + 450 - + (anchor.x * north.x + anchor.y * north.y + anchor.z * north.z) / 8000, + }; + }); parts.rendering = createRendering({ state, services, parts }); parts.photos = { get: () => null, setAmbient() {} }; parts.selection = createSelection({ state, services, parts }); diff --git a/src/layers/wildlifeSightings/rendering.js b/src/layers/wildlifeSightings/rendering.js index 811e98d29..ee47c2759 100644 --- a/src/layers/wildlifeSightings/rendering.js +++ b/src/layers/wildlifeSightings/rendering.js @@ -1,3 +1,6 @@ +import * as geo from '../../geo/index.js'; +import { rgbaFromCss } from '../shared/color.js'; +import { SPRITE_LAYER_ORDER } from '../../data/spriteOrder.js'; import { wildlifeAccent } from './cards.js'; import { wildlifeIconModelWork, @@ -6,7 +9,8 @@ import { } from './model.js'; import { createWildlifeBillboardModel } from './billboardModel.js'; import { createWildlifeDensity } from './density.js'; -import { wildlifeSpriteCache } from './sprites.js'; +import { createWildlifeSpriteBank } from './display.js'; +import { wildlifeSpriteCache, wildlifeSpriteGroup } from './sprites.js'; import { isRecentWildlifeSighting } from './records.js'; import { drainWildlifeWork, @@ -17,19 +21,59 @@ import { import { WILDLIFE_SCALE_BY_DISTANCE, WILDLIFE_BADGE_MAX_PX, - WILDLIFE_GROUND_READY_MAX_CHECKS, WILDLIFE_SPRITE_PX, } from './policy.js'; -/** Own sighting billboards, density glow and batched privacy cells. */ +/** The badges' mover set: its id is the owner a pick of a badge names. */ +export const WILDLIFE_BADGE_SET_ID = 'wildlife-sightings'; +export const WILDLIFE_BADGE_ORDER = + SPRITE_LAYER_ORDER.indexOf('wildlife-sightings'); +const CELL_LINE_PREFIX = 'wildlife-sightings-cell:'; +const CELL_LINE_WIDTH_PX = 2; +// Points per privacy-cell edge, so long edges follow the surface. +const CELL_EDGE_STEPS = 8; + +/** The closed outline of a privacy cell at `height` metres, as flat ECEF. */ +export function wildlifeCellOutline({ south, west, north, east }, height = 0) { + const corners = [ + [west, south], + [east, south], + [east, north], + [west, north], + [west, south], + ]; + const flat = new Float64Array((4 * CELL_EDGE_STEPS + 1) * 3); + let at = 0; + const scratch = geo.cartesian3.create(); + for (let edge = 0; edge < 4; edge++) { + const [lon0, lat0] = corners[edge]; + const [lon1, lat1] = corners[edge + 1]; + for (let step = 0; step < CELL_EDGE_STEPS + (edge === 3 ? 1 : 0); step++) { + const t = step / CELL_EDGE_STEPS; + geo.cartesian3.fromDegrees( + lon0 + (lon1 - lon0) * t, + lat0 + (lat1 - lat0) * t, + height, + undefined, + scratch, + ); + flat[at++] = scratch.x; + flat[at++] = scratch.y; + flat[at++] = scratch.z; + } + } + return flat; +} + +/** Own sighting badges, density glow and privacy-cell outlines on the engine. */ export function createRendering({ state, services, parts }) { - const C = services.cesium; const badgeSprites = services.badgeSprites ?? wildlifeSpriteCache(); let batchDepth = 0; let pendingRenderReason = null; let billboards = null; let density = null; let cellsSignature = null; + let cellLineSerial = 0; let ambientAnchorIds = new Set(); const badgeScreens = new Map(); const crowdedBadgeIds = new Set(); @@ -46,16 +90,20 @@ export function createRendering({ state, services, parts }) { function surfaceAnchor(row) { const old = surfaceAnchors.get(row); if (old && old.lat === row.lat && old.lon === row.lon) return old.anchor; - const anchor = C.Cartesian3.fromDegrees(row.lon, row.lat); + const anchor = geo.cartesian3.fromDegrees(row.lon, row.lat); surfaceAnchors.set(row, { lat: row.lat, lon: row.lon, anchor }); if (operations) operations.positionBuilds++; return anchor; } + function commit() { + state.badges?.commit(); + } const requestRender = (reason) => { if (batchDepth) { pendingRenderReason = reason; return; } + commit(); services.render?.governorRequestRender?.(`wildlife-sightings-${reason}`); }; function beginBatch() { @@ -69,7 +117,6 @@ export function createRendering({ state, services, parts }) { requestRender(reason); } const surfaceRemovers = []; - const tilesetRemovers = new Map(); // Reuse the shared ground cache. One bounded resolve lifts cold anchors // without per-sighting scene renders and pixel readback. @@ -84,7 +131,7 @@ export function createRendering({ state, services, parts }) { queueMicrotask(warmColdFloors); } return Number.isFinite(floor) && floor !== 0 - ? C.Cartesian3.fromDegrees(row.lon, row.lat, floor) + ? geo.cartesian3.fromDegrees(row.lon, row.lat, floor) : surfaceAnchor(row); } @@ -105,9 +152,12 @@ export function createRendering({ state, services, parts }) { }); } + /** The WGS84 horizon test for the current camera, or null without a scene. */ function occluder() { - const camera = state.viewer?.camera; - return camera?.positionWC ? services.occluderFactory?.(camera) : null; + const projector = state.engine?.projector; + return projector + ? { isPointVisible: (point) => projector.horizonVisible(point) } + : null; } function cull() { @@ -126,6 +176,14 @@ export function createRendering({ state, services, parts }) { point.show = show; changed = true; } + // A depth-free outline shows through the globe; keep only front ones. + for (const cell of state.cells) { + const shown = !horizon || horizon.isPointVisible(cell.center) === true; + if (cell.shown === shown) continue; + cell.shown = shown; + cell.line.show(shown); + changed = true; + } if (changed) requestRender('cull'); return changed; } @@ -196,14 +254,14 @@ export function createRendering({ state, services, parts }) { } function resampleAnchors() { - if (!state.enabled || !state.points) return; + if (!state.enabled || !state.badges) return; let changed = false; for (const row of state.renderedRows) { if (row.origin === 'acartia') continue; const point = state.pointById.get(row.stableId); if (!point) continue; const anchor = position(row); - if (C.Cartesian3.equals(anchor, state.positions.get(row.stableId))) + if (geo.cartesian3.equals(anchor, state.positions.get(row.stableId))) continue; point.position = anchor; state.positions.set(row.stableId, anchor); @@ -223,64 +281,35 @@ export function createRendering({ state, services, parts }) { } } - function trackTileset(primitive) { - if ( - tilesetRemovers.has(primitive) || - typeof primitive?.allTilesLoaded?.addEventListener !== 'function' - ) - return false; - tilesetRemovers.set( - primitive, - primitive.allTilesLoaded.addEventListener(resampleAnchors), - ); - return true; - } - function deactivate() { workQueue.cancel(); operations = null; for (const remove of surfaceRemovers.splice(0)) remove?.(); - for (const remove of tilesetRemovers.values()) remove?.(); - tilesetRemovers.clear(); } function activate() { deactivate(); - if (!state.enabled || !state.viewer) return; - const scene = state.viewer.scene; + const engine = state.engine; + if (!state.enabled || !engine) return; const refreshBadgeImages = () => { - if (!state.enabled) return; - appearances.clear(); - if (billboards?.refreshImages((group) => badgeSprites.get(group))) + if (!state.enabled || !state.badges) return; + // The icon font replaces the plain atlas; every badge keeps its slot. + if (state.badges.setAtlas(badgeSprites.atlas())) requestRender('font-ready'); }; surfaceRemovers.push(badgeSprites.subscribe(refreshBadgeImages)); refreshBadgeImages(); - const ownEvent = (event, listener) => { - const remove = event?.addEventListener?.(listener); - if (typeof remove === 'function') surfaceRemovers.push(remove); - }; - ownEvent(scene.globe?.tileLoadProgressEvent, (pending) => { - if (pending === 0) resampleAnchors(); - }); - // Scene forwards the globe event in Cesium; deduplicate that alias. - for (const event of new Set([ - scene.terrainProviderChanged, - scene.globe?.terrainProviderChanged, - ])) - ownEvent(event, resampleAnchors); - const primitives = scene.primitives; - for (let i = 0; i < (primitives?.length || 0); i++) - trackTileset(primitives.get(i)); - ownEvent(primitives?.primitiveAdded, (primitive) => { - if (trackTileset(primitive)) resampleAnchors(); - }); - ownEvent(primitives?.primitiveRemoved, (primitive) => { - if (!tilesetRemovers.has(primitive)) return; - tilesetRemovers.get(primitive)?.(); - tilesetRemovers.delete(primitive); - resampleAnchors(); - }); + // The ground floor cache warms as tiles arrive: resample once the + // surface has settled, on the frame after loading ends. + let loaded = engine.surface.tilesLoaded(); + surfaceRemovers.push( + engine.frames.postRender.addEventListener(() => { + const now = engine.surface.tilesLoaded(); + if (now && !loaded) resampleAnchors(); + loaded = now; + }), + ); + // Terrain, tileset and imagery changes all go through the map stack. services.eventTarget?.addEventListener?.( 'gev:map-stack-changed', resampleAnchors, @@ -293,28 +322,14 @@ export function createRendering({ state, services, parts }) { ); } - function removeCell(primitive) { - const owners = - state.viewer?.scene?.groundPrimitives || state.viewer?.scene?.primitives; - if (owners?.remove?.(primitive) !== true && !primitive.isDestroyed?.()) - primitive.destroy?.(); - } - - function cancelStaging() { - const pending = state.staging; - if (!pending) return; - state.staging = null; - pending.remove?.(); - for (const primitive of pending.primitives) removeCell(primitive); - } - function clearCells() { - cancelStaging(); - for (const primitive of state.cells) removeCell(primitive); + for (const cell of state.cells) cell.line.destroy(); state.cells = []; cellsSignature = null; } + // Privacy cells as outlines in their taxon accent. Outlines sit at the + // cell's sampled ground height and draw through the scene, as the badges do. function stageCells(rows) { const cellsVisible = state.band === 'cells' || state.band === 'cards'; const unique = new Map(); @@ -328,112 +343,35 @@ export function createRendering({ state, services, parts }) { } const signature = JSON.stringify([...unique.keys()].sort()); if (signature === cellsSignature) return false; - cancelStaging(); - if (!unique.size) { - clearCells(); - cellsSignature = signature; - return true; - } + clearCells(); cellsSignature = signature; - const fills = []; - const outlines = []; + if (!unique.size) return true; + const horizon = occluder(); for (const row of unique.values()) { const { south, west, north, east } = row.cell; - const tint = C.Color.fromCssColorString(wildlifeAccent(row.iconic)); - fills.push( - new C.GeometryInstance({ - geometry: new C.RectangleGeometry({ - rectangle: C.Rectangle.fromDegrees(west, south, east, north), - vertexFormat: C.PerInstanceColorAppearance.VERTEX_FORMAT, - }), - attributes: { - color: C.ColorGeometryInstanceAttribute.fromColor( - tint.withAlpha(0.25), - ), - }, - }), - ); - outlines.push( - new C.GeometryInstance({ - geometry: new C.GroundPolylineGeometry({ - positions: C.Cartesian3.fromDegreesArray([ - west, - south, - east, - south, - east, - north, - west, - north, - west, - south, - ]), - width: 2, - }), - attributes: { - color: C.ColorGeometryInstanceAttribute.fromColor( - tint.withAlpha(0.8), - ), - }, - }), - ); + const lat = (south + north) / 2; + const lon = (west + east) / 2; + const floor = services.groundFloor?.cachedGroundFloor?.(lat, lon); + const height = Number.isFinite(floor) ? floor : 0; + const line = state.engine.lines.createLine({ + id: `${CELL_LINE_PREFIX}${++cellLineSerial}`, + widthPx: CELL_LINE_WIDTH_PX, + color: rgbaFromCss(wildlifeAccent(row.iconic)).withAlpha(0.8).rgba(), + depthFailAlpha: 1, + }); + line.setPositions(wildlifeCellOutline(row.cell, height)); + const center = geo.cartesian3.fromDegrees(lon, lat, height); + const shown = !horizon || horizon.isPointVisible(center) === true; + if (!shown) line.show(false); + state.cells.push({ line, center, shown }); } - // Hidden primitives still compile their geometry. Poll only these two - // nonempty batches, then atomically retire the previous privacy cells. - const primitives = [ - new C.GroundPrimitive({ - geometryInstances: fills, - appearance: new C.PerInstanceColorAppearance({ - flat: true, - translucent: true, - }), - classificationType: C.ClassificationType.BOTH, - allowPicking: false, - releaseGeometryInstances: false, - show: false, - }), - new C.GroundPolylinePrimitive({ - geometryInstances: outlines, - appearance: new C.PolylineColorAppearance(), - classificationType: C.ClassificationType.BOTH, - allowPicking: false, - releaseGeometryInstances: false, - show: false, - }), - ]; - const owners = - state.viewer.scene.groundPrimitives || state.viewer.scene.primitives; - for (const primitive of primitives) owners.add(primitive); - const pending = { primitives, remove: null, checks: 0 }; - state.staging = pending; - const ready = () => { - if (state.staging !== pending || !state.enabled) return; - if (!primitives.every((primitive) => primitive.ready === true)) { - pending.checks += 1; - if (pending.checks >= WILDLIFE_GROUND_READY_MAX_CHECKS) { - cancelStaging(); - cellsSignature = null; - return; - } - requestRender('staging'); - return; - } - pending.remove?.(); - state.staging = null; - for (const primitive of state.cells) removeCell(primitive); - state.cells = primitives; - for (const primitive of primitives) primitive.show = true; - requestRender('render'); - }; - pending.remove = state.viewer.scene.postRender?.addEventListener?.(ready); - ready(); return true; } function init() { + const engine = state.engine; density = createWildlifeDensity({ - cesium: C, - viewer: state.viewer, + engine, positionForCell: surfaceAnchor, requestRender, setTimer: services.setTimer, @@ -442,39 +380,34 @@ export function createRendering({ state, services, parts }) { cancelIdle: services.cancelIdleCallback, eventTarget: services.eventTarget, }); - state.points = new C.BillboardCollection(); - state.points.show = false; - state.viewer.scene.primitives.add(state.points); - services.sprites?.registerSpriteCollection?.( - 'wildlife-sightings', - state.points, - ); - const distanceScale = new C.NearFarScalar(...WILDLIFE_SCALE_BY_DISTANCE); + state.badges = createWildlifeSpriteBank(engine, { + id: WILDLIFE_BADGE_SET_ID, + order: WILDLIFE_BADGE_ORDER, + pickable: true, + depthTest: 'always', + sprite: { + atlas: badgeSprites.atlas(), + sizePx: WILDLIFE_SPRITE_PX, + scaleByDistance: [...WILDLIFE_SCALE_BY_DISTANCE], + }, + }); billboards = createWildlifeBillboardModel({ - collection: state.points, + collection: state.badges, pointById: state.pointById, positions: state.positions, positionForRow: position, appearanceForGroup(group) { + // The atlas cell holds the tint and white glyph; the instance colour + // stays white so it does not tint the glyph as well. if (!appearances.has(group)) - appearances.set(group, { - image: badgeSprites.get(group), - width: WILDLIFE_SPRITE_PX, - height: WILDLIFE_SPRITE_PX, - // The sprite includes its tint and white glyph. Multiplication - // by a taxon colour would tint the glyph as well. - color: C.Color.WHITE, - scale: 1, - scaleByDistance: distanceScale, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }); + appearances.set(group, { cell: wildlifeSpriteGroup(group) }); return appearances.get(group); }, }); } function rebuild() { - if (!state.enabled || !state.points) return false; + if (!state.enabled || !state.badges) return false; workQueue.cancel(); operations = null; const work = rebuildWork(); @@ -505,7 +438,7 @@ export function createRendering({ state, services, parts }) { }; const nowMs = services.now(); const taxa = state.drawnParams?.taxa ?? state.taxa; - const scene = state.viewer.scene; + const engine = state.engine; const horizon = occluder(); const densityResult = yield* density.updateWork( state.gridAvailable ? state.gridCells || [] : [], @@ -517,8 +450,7 @@ export function createRendering({ state, services, parts }) { onOperations: (counts) => Object.assign(operations, counts), }, ); - if (densityResult.changed) - services.sprites?.restoreSpriteOrder?.(state.viewer); + const size = engine.host.size(); const model = yield* wildlifeIconModelWork({ rows: [...state.rows.values()].filter( (row) => @@ -530,27 +462,23 @@ export function createRendering({ state, services, parts }) { selectedRow: state.selectedRow, preferredIds: state.ambientIds, onOperations: (counts) => Object.assign(operations, counts), - projectRow: - typeof services.project === 'function' - ? (row) => { - const anchor = surfaceAnchor(row); - const cached = surfaceAnchors.get(row); - cached.screen = null; - if (horizon && horizon.isPointVisible(anchor) !== true) - return null; - const screen = services.project(scene, anchor); - if ( - !screen || - screen.x < 0 || - screen.y < 0 || - screen.x >= (scene.canvas?.clientWidth ?? Infinity) || - screen.y >= (scene.canvas?.clientHeight ?? Infinity) - ) - return null; - cached.screen = screen; - return screen; - } - : null, + projectRow: (row) => { + const anchor = surfaceAnchor(row); + const cached = surfaceAnchors.get(row); + cached.screen = null; + if (horizon && horizon.isPointVisible(anchor) !== true) return null; + const screen = engine.projector.worldToWindow(anchor); + if ( + !screen || + screen.x < 0 || + screen.y < 0 || + screen.x >= size.width || + screen.y >= size.height + ) + return null; + cached.screen = screen; + return screen; + }, }); yield 'rank'; badgeScreens.clear(); @@ -573,7 +501,7 @@ export function createRendering({ state, services, parts }) { state.renderedRows.map((row) => [row.stableId, row]), ); // Panning can bring two visible photo incumbents into one icon bucket. - // Keep the single billboard winner, but preserve both card identities and + // Keep the single badge winner, but preserve both card identities and // anchors until selection's front-side/window/height checks release them. const ambientAnchors = new Map(); for (const incumbent of state.ambientRows || []) { @@ -639,7 +567,6 @@ export function createRendering({ state, services, parts }) { clearCells(); density?.clear(); billboards?.clear(); - if (state.points) state.points.show = false; state.pointById.clear(); state.positions.clear(); state.renderedRows = []; @@ -649,17 +576,15 @@ export function createRendering({ state, services, parts }) { badgeScreens.clear(); crowdedBadgeIds.clear(); photoCardIds.clear(); + commit(); } + /** Release every engine resource (an engine switch or teardown). */ function destroy() { deactivate(); clear(); - services.sprites?.unregisterSpriteCollection?.( - 'wildlife-sightings', - state.points, - ); - if (state.points) state.viewer?.scene?.primitives?.remove?.(state.points); - state.points = null; + state.badges?.destroy(); + state.badges = null; billboards = null; density?.destroy(); density = null; diff --git a/src/layers/wildlifeSightings/rendering.test.mjs b/src/layers/wildlifeSightings/rendering.test.mjs index 9cea12530..d19b74a83 100644 --- a/src/layers/wildlifeSightings/rendering.test.mjs +++ b/src/layers/wildlifeSightings/rendering.test.mjs @@ -1,13 +1,17 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; -import { createRendering } from './rendering.js'; -import { nearFarScalarValueAtDistance } from '../../data/focusDeemphasis.js'; -import { wildlifeGridCellId } from './gridViewport.js'; +import * as geo from '../../geo/index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { - WILDLIFE_GROUND_READY_MAX_CHECKS, - WILDLIFE_SCALE_BY_DISTANCE, -} from './policy.js'; + createRendering, + WILDLIFE_BADGE_ORDER, + WILDLIFE_BADGE_SET_ID, +} from './rendering.js'; +import { WILDLIFE_DENSITY_SET_ID } from './density.js'; +import { wildlifeNearFarValue } from './display.js'; +import { wildlifeGridCellId } from './gridViewport.js'; +import { WILDLIFE_ATLAS_CELLS } from './sprites.js'; +import { WILDLIFE_SCALE_BY_DISTANCE, ICONIC_TINTS } from './policy.js'; const NOW = Date.parse('2026-11-04T18:00:00Z'); const base = { @@ -22,146 +26,56 @@ const base = { obscured: false, cell: null, }; +const degrees = (value) => (value * Math.PI) / 180; +const lonOf = (position) => + (geo.cartographic.fromCartesian(position).longitude * 180) / Math.PI; +const latOf = (position) => + (geo.cartographic.fromCartesian(position).latitude * 180) / Math.PI; -function event() { - const listeners = new Set(); - return { - addEventListener(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - emit(...args) { - for (const listener of [...listeners]) listener(...args); - }, - get size() { - return listeners.size; - }, - }; -} - -function harness(rows = [base], extra = {}) { - const counts = { - collections: 0, - created: 0, - removed: 0, - cleared: 0, - writes: [], - }; - const tracked = new Set([ - 'position', - 'image', - 'color', - 'scale', - 'show', - 'width', - 'height', - 'scaleByDistance', - ]); - class BillboardCollection extends Cesium.BillboardCollection { - constructor() { - super(); - counts.collections += 1; - } - add(options) { - counts.created += 1; - const point = super.add(options); - return new Proxy(point, { - get: (target, key) => Reflect.get(target, key, target), - set(target, key, value) { - if (tracked.has(key)) counts.writes.push(key); - return Reflect.set(target, key, value, target); - }, - }); - } - remove(point) { - counts.removed += 1; - return super.remove(point); - } - removeAll() { - counts.cleared += 1; - return super.removeAll(); - } - } - class GroundBatch { - constructor(options) { - Object.assign(this, options); - this.ready = false; - } - destroy() { - this.destroyed = true; - } - isDestroyed() { - return this.destroyed === true; - } - } - function primitives() { - const values = []; - return { - values, - get length() { - return values.length; - }, - get: (i) => values[i], - add(primitive) { - values.push(primitive); - return primitive; - }, - remove(primitive) { - const i = values.indexOf(primitive); - if (i < 0) return false; - values.splice(i, 1); - primitive.destroy(); - return true; - }, - }; - } - const postRender = event(); - const terrain = event(); - const tileProgress = event(); - const scene = { - primitives: primitives(), - groundPrimitives: primitives(), - postRender, - terrainProviderChanged: terrain, - globe: { - terrainProviderChanged: terrain, - tileLoadProgressEvent: tileProgress, - }, - }; +/** The real rendering on a fake engine above the fixture, 100 px a degree. */ +function harness(rows = [base], { project, ...extra } = {}) { + const engine = createFakeEngine(); + engine.fake.setPose({ + longitude: degrees(base.lon), + latitude: degrees(base.lat), + height: 30_000, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }); + let projections = 0; + engine.fake.script('worldToWindow', (position) => { + projections++; + return project + ? project(position) + : { + x: 100 + (lonOf(position) - base.lon) * 100, + y: 100 - (latOf(position) - base.lat) * 100, + }; + }); + let visible = true; + engine.projector.horizonVisible = () => visible; + const counts = { created: 0, removed: 0, cleared: 0, writes: [] }; const state = { enabled: true, - viewer: { scene, camera: { positionWC: {} } }, + engine, rows: new Map(rows.map((row) => [row.stableId, row])), renderedRows: [], rowById: new Map(), pointById: new Map(), positions: new Map(), cells: [], - staging: null, taxa: 'all', selectedId: null, query: { visibleBoxes: [{ south: -90, west: -180, north: 90, east: 180 }] }, band: 'cards', }; const renders = []; - let visible = true; - const registration = new Set(); const rendering = createRendering({ state, services: { now: () => NOW, - cesium: { - ...Cesium, - BillboardCollection, - GroundPrimitive: GroundBatch, - GroundPolylinePrimitive: GroundBatch, - }, - occluderFactory: () => ({ isPointVisible: () => visible }), render: { governorRequestRender: (reason) => renders.push(reason) }, - sprites: { - registerSpriteCollection: (_id, value) => registration.add(value), - unregisterSpriteCollection: (_id, value) => registration.delete(value), - }, ...extra, }, parts: { @@ -176,17 +90,35 @@ function harness(rows = [base], extra = {}) { }, }); rendering.init(); + // Count badge allocations and property writes as the bank makes them. + const bank = state.badges; + const { add, remove, removeAll, write } = bank; + bank.add = (fields) => { + counts.created += 1; + return add.call(bank, fields); + }; + bank.remove = (handle) => { + counts.removed += 1; + return remove.call(bank, handle); + }; + bank.removeAll = () => { + counts.cleared += 1; + return removeAll.call(bank); + }; + bank.write = (handle, fields) => { + counts.writes.push(...Object.keys(fields)); + return write.call(bank, handle, fields); + }; rendering.activate(); return { + engine, state, - scene, rendering, counts, renders, - postRender, - terrain, - tileProgress, - registration, + badges: () => engine.fake.moverSet(WILDLIFE_BADGE_SET_ID), + glow: () => engine.fake.moverSet(WILDLIFE_DENSITY_SET_ID), + projections: () => projections, visible(value) { visible = value; }, @@ -196,32 +128,52 @@ function harness(rows = [base], extra = {}) { }; } -test('real Cesium billboard values update in place, and unchanged refreshes write nothing', () => { +test('badge sprites update in place, and unchanged refreshes write nothing', () => { const second = { ...base, stableId: 'synthetic:2', lon: -120 }; const h = harness([base, second]); h.rendering.rebuild(); - const collection = h.state.points; + const set = h.badges(); const incumbent = h.state.pointById.get(base.stableId); - assert.equal(h.counts.collections, 1); assert.equal(h.counts.created, 2); - assert.equal(incumbent.width * incumbent.scaleByDistance.farValue, 12); + assert.equal(set.options.kind, 'sprite'); + assert.equal(set.options.pickable, true); + assert.equal(set.options.depthTest, 'always'); + assert.equal(set.options.order, WILDLIFE_BADGE_ORDER); + assert.equal(set.store.count, 2); + assert.equal( + set.options.sprite.sizePx * set.options.sprite.scaleByDistance[3], + 12, + ); assert.deepEqual( - incumbent.scaleByDistance, - new Cesium.NearFarScalar(...WILDLIFE_SCALE_BY_DISTANCE), + [...set.options.sprite.scaleByDistance], + WILDLIFE_SCALE_BY_DISTANCE, + ); + assert.deepEqual(incumbent.color, [1, 1, 1, 1]); + assert.equal(set.store.pickIds[incumbent.index], `wildlife:${base.stableId}`); + assert.equal( + set.store.cellKeys[set.store.cells[incumbent.index]], + 'bird', + 'the taxon picks its atlas cell', ); - assert.equal(incumbent.color.alpha, 1); h.counts.writes.length = 0; h.renders.length = 0; + const commits = h.badges().commits; h.rows([{ ...second }, { ...base }]); assert.equal(h.rendering.rebuild(), false); assert.equal(h.rendering.cull(), false); assert.deepEqual(h.counts.writes, []); assert.deepEqual(h.renders, []); + assert.equal(h.badges().commits, commits, 'nothing uploads'); h.rows([{ ...base, lat: base.lat + 0.01 }, second]); assert.equal(h.rendering.rebuild(), true); assert.deepEqual(h.counts.writes, ['position']); + assert.equal(h.badges().commits, commits + 1, 'one upload per rebuild'); assert.equal(h.state.pointById.get(base.stableId), incumbent); - assert.equal(h.state.points, collection); + const at = 3 * incumbent.index; + assert.deepEqual( + [...set.store.positions.subarray(at, at + 3)], + [incumbent.position.x, incumbent.position.y, incumbent.position.z], + ); h.rows([ { ...base, lat: base.lat + 0.01 }, { ...second, stableId: 'synthetic:3' }, @@ -230,12 +182,12 @@ test('real Cesium billboard values update in place, and unchanged refreshes writ assert.equal(h.counts.created, 3); assert.equal(h.counts.removed, 1); assert.equal(h.counts.cleared, 0); + assert.equal(h.engine.frames.postRender.numberOfListeners, 1); h.rendering.destroy(); - assert.equal(h.registration.size, 0); - assert.equal(h.scene.primitives.length, 0); + assert.equal(h.badges(), undefined); + assert.deepEqual(h.engine.movers.list(), []); assert.equal(h.state.pointById.size, 0); - assert.equal(h.terrain.size, 0); - assert.equal(h.tileProgress.size, 0); + assert.equal(h.engine.frames.postRender.numberOfListeners, 0); }); test('horizon changes write only changed visibility, with one governor request per batch', () => { @@ -250,6 +202,7 @@ test('horizon changes write only changed visibility, with one governor request p h.rendering.endBatch(); assert.deepEqual(h.counts.writes, ['show']); assert.equal(h.renders.length, 1); + assert.equal(h.badges().store.shows[0], 0); h.rendering.cull(); assert.deepEqual(h.counts.writes, ['show']); assert.equal(h.renders.length, 1); @@ -260,6 +213,7 @@ test('badge pixels stay between 12 and 22 at near, mid and far heights, includin const h = harness(); h.rendering.rebuild(); const wildlife = h.state.pointById.get(base.stableId); + const { sizePx, scaleByDistance } = h.badges().options.sprite; for (const selectedId of [null, base.stableId]) { h.state.selectedId = selectedId; h.rendering.refreshSelection(); @@ -267,9 +221,9 @@ test('badge pixels stay between 12 and 22 at near, mid and far heights, includin 0, 1000, 9000, 120_000, 400_000, 1_500_000, 8_000_000, 10_000_000, ]) { const pixels = - wildlife.width * + sizePx * wildlife.scale * - nearFarScalarValueAtDistance(wildlife.scaleByDistance, heightM); + wildlifeNearFarValue(scaleByDistance, heightM); assert.ok( pixels >= 12 && pixels <= 22, `${heightM}m, selected=${!!selectedId}: ${pixels}px`, @@ -277,8 +231,8 @@ test('badge pixels stay between 12 and 22 at near, mid and far heights, includin } } assert.deepEqual( - wildlife.color, - Cesium.Color.WHITE, + [...h.badges().store.colors.subarray(0, 4)], + [1, 1, 1, 1], 'keep the baked white glyph white', ); h.rendering.destroy(); @@ -292,15 +246,10 @@ test('adjacent 28px bucket edges never overlap badges or erase sighting counts', x, })); const h = harness(rows, { - project: (_scene, anchor) => { - const lon = Cesium.Math.toDegrees( - Cesium.Cartographic.fromCartesian(anchor).longitude, - ); - return { - x: rows.find((row) => Math.abs(row.lon - lon) < 1e-7).x, - y: 100, - }; - }, + project: (anchor) => ({ + x: rows.find((row) => Math.abs(row.lon - lonOf(anchor)) < 1e-7).x, + y: 100, + }), }); h.rendering.rebuild(); assert.equal(h.state.sightingCount, 4); @@ -316,6 +265,12 @@ test('adjacent 28px bucket edges never overlap badges or erase sighting counts', for (let i = 0; i < visible.length; i++) for (let j = i + 1; j < visible.length; j++) assert.ok(Math.abs(visible[i].x - visible[j].x) >= 22); + const shows = h.badges().store.shows; + assert.equal( + [...shows.subarray(0, 4)].filter(Boolean).length, + 3, + 'the engine draws the same three', + ); h.counts.writes.length = 0; h.renders.length = 0; h.rendering.rebuild(); @@ -338,6 +293,7 @@ test('only painted photo cards hide their anchored badges, with zero repeated vi h.renders.length = 0; h.rendering.setPhotoCardVisibility(new Set([`sighting:${base.stableId}`])); assert.equal(point.show, false); + assert.equal(h.badges().store.shows[point.index], 0); assert.deepEqual(h.counts.writes, ['show']); h.rendering.rebuild(); h.rendering.cull(); @@ -365,18 +321,11 @@ test('sampled fallback buckets allocate only representatives at every height, wi rows.map((row) => [row.lon.toFixed(6), { x: 100, y: 100 }]), ); const h = harness(rows, { - project: (_scene, anchor) => { - const lon = Cesium.Math.toDegrees( - Cesium.Cartographic.fromCartesian(anchor).longitude, - ); - return screens.get(lon.toFixed(6)); - }, + project: (anchor) => screens.get(lonOf(anchor).toFixed(6)), + }); + h.engine.fake.setPose({ + position: geo.cartesian3.fromDegrees(base.lon, base.lat, heightM), }); - h.state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( - base.lon, - base.lat, - heightM, - ); h.rendering.rebuild(); assert.equal( h.state.pointById.size, @@ -393,20 +342,18 @@ test('sampled fallback buckets allocate only representatives at every height, wi assert.equal(h.rendering.cull(), false); assert.equal(h.rendering.rebuild(), false); assert.deepEqual(h.counts.writes, []); - assert.equal(h.postRender.size, 0); + assert.equal( + h.engine.frames.preRender.numberOfListeners, + 0, + 'badges need no frame work', + ); h.rendering.destroy(); } }); test('UTFGrid creates a dedicated unpickable glow and observations alone create badges', () => { - let projections = 0; - const h = harness([base], { - project() { - projections++; - return { x: 100, y: 100 }; - }, - }); - h.scene.canvas = { clientWidth: 280, clientHeight: 200 }; + const h = harness([base], { project: () => ({ x: 100, y: 100 }) }); + h.engine.fake.setSize(280, 200); h.state.gridAvailable = true; h.state.gridCells = [1, 50, 2].map((cellCount, index) => ({ id: wildlifeGridCellId(base.lat, base.lon + index * 0.01, 16), @@ -414,32 +361,48 @@ test('UTFGrid creates a dedicated unpickable glow and observations alone create lon: base.lon + index * 0.01, cellCount, })); + const before = h.projections(); h.rendering.rebuild(); - const glow = h.rendering.density.collection; + const glow = h.glow(); assert.ok(glow); - assert.equal(glow.allowPicking, false); - assert.equal(glow.length, 3); - assert.equal(h.scene.primitives.length, 2); + assert.equal(glow.options.pickable, false); + assert.ok(glow.options.order < WILDLIFE_BADGE_ORDER, 'glow under badges'); + assert.equal(glow.store.count, 3); + assert.equal(h.engine.movers.list().length, 2); assert.deepEqual([...h.state.pointById.keys()], [base.stableId]); assert.deepEqual(h.state.renderedRows, [base]); assert.equal(h.state.sightingCount, 53); - assert.equal(projections, 1, 'only observations require screen projections'); - assert.equal(h.postRender.size, 0, 'density installs no frame listener'); + assert.equal( + h.projections() - before, + 1, + 'only observations require screen projections', + ); + assert.equal( + h.engine.frames.preRender.numberOfListeners, + 1, + 'metre-sized glow follows the camera', + ); + const glowSet = h.rendering.density.collection.set; h.state.gridCells = [...h.state.gridCells].reverse(); h.counts.writes.length = 0; assert.equal(h.rendering.rebuild(), false); - assert.equal(h.rendering.density.collection, glow); + assert.equal(h.rendering.density.collection.set, glowSet); assert.deepEqual(h.counts.writes, []); h.rendering.clear(); - assert.equal(glow.isDestroyed(), true); - assert.equal(h.scene.primitives.length, 1); - assert.equal(h.state.points.length, 0); + assert.equal(glowSet.isDestroyed(), true); + assert.equal(h.glow(), undefined); + assert.equal(h.engine.frames.preRender.numberOfListeners, 0); + assert.equal(h.badges().store.count, 0); h.rendering.destroy(); - assert.equal(h.scene.primitives.length, 0); + assert.deepEqual(h.engine.movers.list(), []); }); test('tile arrival or loss cannot replace a real observation with a grid badge', () => { const h = harness(); + // High enough that the continental kernel stays in front of the limb. + h.engine.fake.setPose({ + position: geo.cartesian3.fromDegrees(base.lon, base.lat, 2_000_000), + }); h.rendering.rebuild(); const badge = h.state.pointById.get(base.stableId); h.state.gridAvailable = true; @@ -452,7 +415,7 @@ test('tile arrival or loss cannot replace a real observation with a grid badge', }, ]; h.rendering.rebuild(); - const glow = h.rendering.density.collection; + const glow = h.rendering.density.collection.set; assert.equal(h.state.sightingCount, 10_000); assert.equal(h.state.pointById.get(base.stableId), badge); h.state.gridAvailable = false; @@ -474,39 +437,79 @@ test('a selected observation remains rendered outside the viewport and observati assert.equal(h.state.rowById.get(selected.stableId), selected); assert.equal(h.state.pointById.has(selected.stableId), true); h.state.band = 'regional'; - h.state.viewer.camera.positionCartographic = { height: 1_500_000 }; + h.engine.fake.setPose({ + position: geo.cartesian3.fromDegrees(base.lon, base.lat, 1_500_000), + }); h.rendering.rebuild(); assert.equal(h.state.selectedId, selected.stableId); assert.equal(h.state.rowById.get(selected.stableId), selected); h.rendering.destroy(); }); -test('privacy ground staging has a bounded readiness listener and complete teardown', () => { - const h = harness([ - { - ...base, - obscured: true, - cell: { south: 47.6, west: -122.4, north: 47.8, east: -122.2 }, - }, - ]); +test('privacy cells draw accent outlines through the scene and release them', () => { + const cell = { south: 47.6, west: -122.4, north: 47.8, east: -122.2 }; + const h = harness([{ ...base, obscured: true, cell }], { + groundFloor: { cachedGroundFloor: () => 250 }, + }); h.rendering.rebuild(); - assert.equal(h.postRender.size, 1); - assert.equal(h.scene.groundPrimitives.length, 2); - for (let i = 0; i < WILDLIFE_GROUND_READY_MAX_CHECKS; i++) - h.postRender.emit(); - assert.equal(h.postRender.size, 0); - assert.equal(h.state.staging, null); - assert.equal(h.scene.groundPrimitives.length, 0); + const lines = h.engine.lines.list(); + assert.equal(lines.length, 1); + const line = h.engine.fake.line(lines[0].id); + assert.equal(line.options.widthPx, 2); + assert.equal(line.options.depthFailAlpha, 1); + const tint = ICONIC_TINTS.Aves; + assert.deepEqual( + line.color.map((value) => Math.round(value * 255)), + [ + parseInt(tint.slice(1, 3), 16), + parseInt(tint.slice(3, 5), 16), + parseInt(tint.slice(5, 7), 16), + Math.round(0.8 * 255), + ], + ); + const flat = line.positions; + const first = geo.cartesian3.create(flat[0], flat[1], flat[2]); + const last = geo.cartesian3.create(...flat.slice(-3)); + assert.ok(geo.cartesian3.distance(first, last) < 1e-6, 'a closed ring'); + assert.ok(Math.abs(lonOf(first) - cell.west) < 1e-9); + assert.ok(Math.abs(latOf(first) - cell.south) < 1e-9); + assert.ok( + Math.abs(geo.cartographic.fromCartesian(first).height - 250) < 1e-3, + 'at the sampled ground height', + ); + assert.equal(line.shown, true); + // An unchanged cell set keeps its lines. + h.rendering.rebuild(); + assert.deepEqual(h.engine.lines.list(), lines); + h.visible(false); + h.rendering.cull(); + assert.equal(line.shown, false, 'outlines behind the globe hide'); + h.visible(true); + h.rendering.cull(); + assert.equal(line.shown, true); + h.state.band = 'regional'; + h.rendering.rebuild(); + assert.deepEqual(h.engine.lines.list(), [], 'wide views draw no cells'); + h.state.band = 'cards'; + h.rendering.rebuild(); + assert.equal(h.engine.lines.list().length, 1); h.rendering.destroy(); - assert.equal(h.postRender.size, 0); + assert.deepEqual(h.engine.lines.list(), []); }); -test('font readiness swaps images in place without billboard recreation or position work', () => { +test('font readiness swaps the atlas in place without badge recreation or position work', () => { const listeners = new Set(); let ready = false; + const plain = { + url: 'data:image/svg+xml,plain', + cells: WILDLIFE_ATLAS_CELLS, + }; + const glyphs = { + url: 'data:image/svg+xml,glyphs', + cells: WILDLIFE_ATLAS_CELLS, + }; const badgeSprites = { - get: (group) => - `data:image/svg+xml,${encodeURIComponent(``)}`, + atlas: () => (ready ? glyphs : plain), subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); @@ -518,23 +521,23 @@ test('font readiness swaps images in place without billboard recreation or posit ]; const h = harness(rows, { badgeSprites }); h.rendering.rebuild(); - const collection = h.state.points; + assert.equal(h.badges().options.sprite.atlas, plain); const points = new Map(h.state.pointById); - const before = new Map([...points].map(([id, point]) => [id, point.image])); + const cells = [...h.badges().store.cells.subarray(0, 2)]; h.counts.writes.length = 0; h.renders.length = 0; ready = true; for (const listener of listeners) listener(); - assert.equal(h.state.points, collection); + assert.equal(h.badges().options.sprite.atlas, glyphs); + assert.equal(h.badges().store.count, 2); + assert.deepEqual([...h.badges().store.cells.subarray(0, 2)], cells); assert.equal(h.counts.created, 2); assert.equal(h.counts.removed, 0); assert.equal(h.counts.cleared, 0); - assert.deepEqual(h.counts.writes, ['image', 'image']); + assert.deepEqual(h.counts.writes, []); assert.equal(h.renders.length, 1); - for (const [id, point] of points) { + for (const [id, point] of points) assert.equal(h.state.pointById.get(id), point); - assert.notEqual(point.image, before.get(id)); - } h.counts.writes.length = 0; h.rows(rows.map((row) => ({ ...row, lat: row.lat + 0.01 }))); h.rendering.rebuild(); @@ -542,3 +545,28 @@ test('font readiness swaps images in place without billboard recreation or posit h.rendering.destroy(); assert.equal(listeners.size, 0); }); + +test('badges resample when the surface finishes loading, on the frame after', () => { + let floor = 0; + const h = harness([base], { + groundFloor: { cachedGroundFloor: () => floor }, + }); + let loaded = false; + h.engine.fake.script('tilesLoaded', () => loaded); + h.rendering.activate(); + h.rendering.rebuild(); + const point = h.state.pointById.get(base.stableId); + floor = 600; + h.engine.fake.frame(); + assert.ok( + Math.abs(geo.cartographic.fromCartesian(point.position).height) < 1e-3, + ); + loaded = true; + h.engine.fake.frame(); + assert.ok( + Math.abs(geo.cartographic.fromCartesian(point.position).height - 600) < + 1e-3, + ); + assert.equal(h.state.pointById.get(base.stableId), point); + h.rendering.destroy(); +}); diff --git a/src/layers/wildlifeSightings/selection.js b/src/layers/wildlifeSightings/selection.js index 050da3fb5..1d5f0c8ba 100644 --- a/src/layers/wildlifeSightings/selection.js +++ b/src/layers/wildlifeSightings/selection.js @@ -156,7 +156,7 @@ export function createSelection({ state, services, parts }) { const selected = selectionRow(); if ( selected && - (state.viewer.camera?.positionCartographic?.height ?? 0) < + (state.engine?.camera.height() ?? 0) < WILDLIFE_SELECTED_LABEL_FADE_HIGH_METERS ) remember(selected); @@ -196,11 +196,10 @@ export function createSelection({ state, services, parts }) { } function refreshLabels() { - if (!state.enabled || !overlay) return; - const scene = state.viewer.scene; + const engine = state.engine; + if (!state.enabled || !overlay || !engine) return; const horizon = parts.rendering.occluder(); - const width = scene.canvas?.clientWidth || 0; - const height = scene.canvas?.clientHeight || 0; + const { width, height } = engine.host.size(); // Rank during camera/data passes; the host handles pixel collisions. const nowMs = services.now(); const candidates = []; @@ -212,7 +211,7 @@ export function createSelection({ state, services, parts }) { const position = state.positions.get(row.stableId); if (!position || (horizon && horizon.isPointVisible(position) !== true)) continue; - const screen = services.project(scene, position); + const screen = engine.projector.worldToWindow(position); if ( !screen || screen.x < 0 || @@ -236,10 +235,12 @@ export function createSelection({ state, services, parts }) { } function ambientCandidates(rows) { - const scene = state.viewer.scene; + const engine = state.engine; const horizon = parts.rendering.occluder(); - const width = scene.canvas?.clientWidth ?? Infinity; - const height = scene.canvas?.clientHeight ?? Infinity; + const { width, height } = engine?.host.size() ?? { + width: Infinity, + height: Infinity, + }; const screens = new Map(); const nowMs = services.now(); const taxa = state.drawnParams?.taxa ?? state.taxa; @@ -251,9 +252,13 @@ export function createSelection({ state, services, parts }) { ) return false; const position = state.positions.get(row.stableId); - if (!position || (horizon && horizon.isPointVisible(position) !== true)) + if ( + !engine || + !position || + (horizon && horizon.isPointVisible(position) !== true) + ) return false; - const screen = services.project(scene, position); + const screen = engine.projector.worldToWindow(position); if ( !screen || screen.x < 0 || @@ -272,8 +277,7 @@ export function createSelection({ state, services, parts }) { * photos survive; acquisition also checks the observation window/params. */ function retainedAmbientRows() { if (!state.enabled) return []; - const height = - state.viewer?.camera?.positionCartographic?.height ?? Infinity; + const height = state.engine?.camera.height() ?? Infinity; if (height >= WILDLIFE_CARD_HIDE_MAX_METERS) return []; return ambientCandidates(state.ambientRows).eligible; } @@ -281,8 +285,7 @@ export function createSelection({ state, services, parts }) { function refreshAmbient() { if (!state.enabled) return; refreshLabels(); - const height = - state.viewer?.camera?.positionCartographic?.height ?? Infinity; + const height = state.engine?.camera.height() ?? Infinity; if (state.ambientActive) { if (height >= WILDLIFE_CARD_HIDE_MAX_METERS) state.ambientActive = false; } else if (height <= WILDLIFE_CARD_SHOW_MAX_METERS) @@ -317,8 +320,11 @@ export function createSelection({ state, services, parts }) { // iNaturalist is static; Acartia enters the attribution popover on first use. function syncCredits() { if (!state.enabled) return; - if (state.renderedRows.some((row) => row.origin === 'acartia')) - registerDynamicCredit(state.viewer, ACARTIA_CREDIT); + if ( + state.engine && + state.renderedRows.some((row) => row.origin === 'acartia') + ) + registerDynamicCredit(state.engine, ACARTIA_CREDIT); } function registerContext(row) { @@ -469,50 +475,66 @@ export function createSelection({ state, services, parts }) { 'gev:entity-selected', onContext, ); - if (typeof services.screenSpaceEventHandlerFactory !== 'function') return; - state.clickHandler = services.screenSpaceEventHandlerFactory(state.viewer); - state.clickHandler.setInputAction((click) => { - if (!state.enabled || services.pointer?.isPointerFree?.() === false) - return; - const hit = - overlay?.hitTest?.(click.position?.x, click.position?.y, { - sourceId: WILDLIFE_SELECTED_SOURCE_ID, - }) || - overlay?.hitTest?.(click.position?.x, click.position?.y, { - sourceId: WILDLIFE_OVERLAY_SOURCE_ID, - }) || - overlay?.hitTest?.(click.position?.x, click.position?.y, { - sourceId: WILDLIFE_LABEL_SOURCE_ID, - }); - if (hit) { - hit.entry?.activate?.(); - return; - } - const picked = state.viewer.scene.pick(click.position); - const id = - services.picking?.resolvePickId?.(picked) ?? - picked?.id ?? - picked?.primitive?.id; - if ( - typeof id === 'string' && - id.startsWith('wildlife:') && - selectAndFocus(id.slice(9)) - ) - return; - if ( - id && - services.picking?.isOwnedByOtherLayer?.('wildlife-sightings', id) - ) - return; - clearAndRebuild(); - }, services.cesium.ScreenSpaceEventType.LEFT_CLICK); + const engine = state.engine; + if (!engine) return; + state.removeClick = engine.input.on('click', onClick, { pick: true }); + } + + function onClick(click) { + if (!state.enabled || services.pointer?.isPointerFree?.() === false) return; + const hit = + overlay?.hitTest?.(click.position?.x, click.position?.y, { + sourceId: WILDLIFE_SELECTED_SOURCE_ID, + }) || + overlay?.hitTest?.(click.position?.x, click.position?.y, { + sourceId: WILDLIFE_OVERLAY_SOURCE_ID, + }) || + overlay?.hitTest?.(click.position?.x, click.position?.y, { + sourceId: WILDLIFE_LABEL_SOURCE_ID, + }); + if (hit) { + hit.entry?.activate?.(); + return; + } + const picked = click.picked; + const id = services.picking?.resolvePickId?.(picked) ?? picked?.id; + if ( + typeof id === 'string' && + id.startsWith('wildlife:') && + selectAndFocus(id.slice(9)) + ) + return; + if (id && services.picking?.isOwnedByOtherLayer?.('wildlife-sightings', id)) + return; + clearAndRebuild(); + } + + /** Stop listening to clicks and context selections. */ + function uninstall() { + state.removeClick?.(); + state.removeClick = null; + state.removeContext?.(); + state.removeContext = null; + } + + function clearOverlays() { + labelScreens.clear(); + for (const id of sourceIds) { + overlay?.clearSource?.(id); + overlay?.setVisible?.(id, false); + } + } + + /** Leave the engine: the selection stays for the next one. */ + function detach() { + uninstall(); + state.ambientRows = []; + state.labelRows = []; + clearOverlays(); } function remove() { - state.clickHandler?.destroy(); - state.clickHandler = null; - state.removeContext?.(); - state.removeContext = null; + uninstall(); clear(); try { services.context?.removeEntityContextsForLayer?.('wildlife-sightings'); @@ -525,11 +547,7 @@ export function createSelection({ state, services, parts }) { state.ambientRows = []; state.ambientActive = false; state.labelRows = []; - labelScreens.clear(); - for (const id of sourceIds) { - overlay?.clearSource?.(id); - overlay?.setVisible?.(id, false); - } + clearOverlays(); } return { @@ -542,6 +560,8 @@ export function createSelection({ state, services, parts }) { listeners.clear(); }, install, + uninstall, + detach, remove, refreshAmbient, retainedAmbientRows, diff --git a/src/layers/wildlifeSightings/selection.test.mjs b/src/layers/wildlifeSightings/selection.test.mjs index 2263fd25e..17a546f2c 100644 --- a/src/layers/wildlifeSightings/selection.test.mjs +++ b/src/layers/wildlifeSightings/selection.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { createSelection } from './selection.js'; const row = { @@ -22,7 +23,14 @@ function fixture(sighting = row) { let hit = null; let projections = 0; let picked = null; - let click; + const engine = createFakeEngine({ width: 1200, height: 900 }); + engine.fake.setPose({ height: 30_000 }); + // Anchors here are screen points already. + engine.fake.script('worldToWindow', (anchor) => { + projections++; + return anchor; + }); + engine.fake.script('pick', () => picked); const state = { enabled: true, destroyed: false, @@ -36,13 +44,7 @@ function fixture(sighting = row) { ambientIds: [], ambientRows: [], labelRows: [], - viewer: { - scene: { - pick: () => picked, - canvas: { clientWidth: 1200, clientHeight: 900 }, - }, - camera: { positionCartographic: { height: 30_000 } }, - }, + engine, }; const selection = createSelection({ state, @@ -55,16 +57,7 @@ function fixture(sighting = row) { hit?.sourceId === sourceId ? hit : null, }, now: () => row.observedAt + 60_000, - project: (_scene, anchor) => { - projections++; - return anchor; - }, focus: { requestWorldFocus: (request) => flights.push(request) }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction: (fn) => (click = fn), - destroy() {}, - }), - cesium: { ScreenSpaceEventType: { LEFT_CLICK: 0 } }, eventTarget: new EventTarget(), }, parts: { @@ -100,8 +93,8 @@ function fixture(sighting = row) { selection.photoCardsChanged(); }, click(id = `wildlife:${sighting.stableId}`) { - picked = id ? { id } : null; - click({ position: { x: 10, y: 20 } }); + picked = id ? { id } : undefined; + engine.fake.input('click', { position: { x: 10, y: 20 } }); }, }; } diff --git a/src/layers/wildlifeSightings/sprites.js b/src/layers/wildlifeSightings/sprites.js index 94f963b05..8dbcb3cba 100644 --- a/src/layers/wildlifeSightings/sprites.js +++ b/src/layers/wildlifeSightings/sprites.js @@ -62,8 +62,33 @@ const FALLBACKS = new Map( ]), ); +// One atlas cell per group, side by side, each WILDLIFE_SPRITE_PX * 2 square. +const CELL_PX = WILDLIFE_SPRITE_PX * 2; +export const WILDLIFE_ATLAS_CELLS = Object.freeze( + Object.fromEntries( + WILDLIFE_SPRITE_GROUPS.map((group, index) => [ + group, + Object.freeze([ + index / WILDLIFE_SPRITE_GROUPS.length, + 0, + (index + 1) / WILDLIFE_SPRITE_GROUPS.length, + 1, + ]), + ]), + ), +); +const FALLBACK_ATLAS = Object.freeze({ + url: `data:image/svg+xml,${encodeURIComponent( + `${WILDLIFE_SPRITE_GROUPS.map( + (group, index) => + ``, + ).join('')}`, + )}`, + cells: WILDLIFE_ATLAS_CELLS, +}); + /** One bounded set of canvas sprites per document, painted only after the - * icon font is decoded. Until then the very same billboards use plain badges. */ + * icon font is decoded. Until then the very same badges use plain circles. */ export function createWildlifeSpriteCache({ documentRef = globalThis.document, } = {}) { @@ -71,6 +96,7 @@ export function createWildlifeSpriteCache({ const listeners = new Set(); let ready = false; let loading = null; + let atlas = null; function paint(group) { const canvas = documentRef.createElement('canvas'); @@ -111,7 +137,28 @@ export function createWildlifeSpriteCache({ return loading; } + // The painted sprites side by side, made once on first use. + function paintAtlas() { + const canvas = documentRef.createElement('canvas'); + canvas.width = CELL_PX * WILDLIFE_SPRITE_GROUPS.length; + canvas.height = CELL_PX; + const ctx = canvas.getContext('2d'); + if (!ctx) return FALLBACK_ATLAS; + WILDLIFE_SPRITE_GROUPS.forEach((group, index) => { + const sprite = sprites.get(group); + if (typeof sprite !== 'string') + ctx.drawImage(sprite, index * CELL_PX, 0, CELL_PX, CELL_PX); + }); + return Object.freeze({ canvas, cells: WILDLIFE_ATLAS_CELLS }); + } + return { + /** Every group's badge in one atlas ({ url | canvas, cells }), keyed by + * sprite group; plain circles until the icon font is ready. */ + atlas() { + if (!ready) return FALLBACK_ATLAS; + return (atlas ??= paintAtlas()); + }, get(groupOrIconic) { const group = wildlifeSpriteGroup(groupOrIconic); return (ready ? sprites : FALLBACKS).get(group); diff --git a/src/layers/wildlifeSightings/sprites.test.mjs b/src/layers/wildlifeSightings/sprites.test.mjs index ae82b7f96..3afe4a9af 100644 --- a/src/layers/wildlifeSightings/sprites.test.mjs +++ b/src/layers/wildlifeSightings/sprites.test.mjs @@ -8,6 +8,7 @@ import { WILDLIFE_SPRITE_ICONIC, WILDLIFE_BADGE_GLYPHS, WILDLIFE_BADGE_OUTLINE_PX, + WILDLIFE_ATLAS_CELLS, createWildlifeSpriteCache, wildlifeSpriteCache, wildlifeSpriteGroup, @@ -146,6 +147,57 @@ test('canvas badges wait for the icon font, paint once and notify existing consu unsubscribe(); }); +test('one atlas holds every group: plain circles first, the painted badges once the font is ready', async () => { + const drawn = []; + const documentRef = { + fonts: { load: async () => [{}] }, + createElement() { + const canvas = { + getContext: () => ({ + scale() {}, + beginPath() {}, + arc() {}, + fill() {}, + stroke() {}, + fillText() {}, + drawImage: (...args) => drawn.push([canvas, ...args]), + }), + }; + return canvas; + }, + }; + const cache = createWildlifeSpriteCache({ documentRef }); + const plain = cache.atlas(); + assert.equal(plain.cells, WILDLIFE_ATLAS_CELLS); + assert.deepEqual(Object.keys(plain.cells), [...WILDLIFE_SPRITE_GROUPS]); + const svg = decodeURIComponent(plain.url.slice(plain.url.indexOf(',') + 1)); + assert.equal(svg.match(/ { + const [u0, v0, u1, v1] = WILDLIFE_ATLAS_CELLS[group]; + assert.equal(u0, index / WILDLIFE_SPRITE_GROUPS.length); + assert.equal(u1, (index + 1) / WILDLIFE_SPRITE_GROUPS.length); + assert.deepEqual([v0, v1], [0, 1]); + }); + assert.equal(await cache.load(), true); + const painted = cache.atlas(); + assert.equal(cache.atlas(), painted, 'painted once'); + assert.equal(painted.cells, WILDLIFE_ATLAS_CELLS); + assert.equal( + painted.canvas.width, + WILDLIFE_SPRITE_PX * 2 * WILDLIFE_SPRITE_GROUPS.length, + ); + assert.equal(painted.canvas.height, WILDLIFE_SPRITE_PX * 2); + assert.equal(drawn.length, WILDLIFE_SPRITE_GROUPS.length); + WILDLIFE_SPRITE_GROUPS.forEach((group, index) => { + const [canvas, image, x] = drawn[index]; + assert.equal(canvas, painted.canvas); + assert.equal(image, cache.get(group)); + assert.equal(x, index * WILDLIFE_SPRITE_PX * 2); + }); +}); + test('unavailable font keeps plain badges and does not paint a ligature word', async () => { const cache = createWildlifeSpriteCache({ documentRef: { diff --git a/src/layers/wildlifeSightings/viewport.js b/src/layers/wildlifeSightings/viewport.js index a217a010d..3811a9cae 100644 --- a/src/layers/wildlifeSightings/viewport.js +++ b/src/layers/wildlifeSightings/viewport.js @@ -44,12 +44,8 @@ export function boxIntersects(a, b) { } /** Split a local camera rectangle into noncrossing observation query boxes. */ -export function wildlifeViewportBoxes(viewer) { - const camera = viewer?.camera; - if (typeof camera?.computeViewRectangle !== 'function') return null; - const rectangle = camera.computeViewRectangle( - viewer?.scene?.globe?.ellipsoid, - ); +export function wildlifeViewportBoxes(engine) { + const rectangle = engine?.camera?.viewRectangle(); if (!rectangle) return null; const box = Object.fromEntries( ['south', 'west', 'north', 'east'].map((key) => [ diff --git a/src/layers/wildlifeSightings/viewport.test.mjs b/src/layers/wildlifeSightings/viewport.test.mjs index 410086886..deee23737 100644 --- a/src/layers/wildlifeSightings/viewport.test.mjs +++ b/src/layers/wildlifeSightings/viewport.test.mjs @@ -8,19 +8,22 @@ import { } from './viewport.js'; import { ACARTIA_GUARD_BOX } from './policy.js'; import { getWildlifeHeightBand } from './model.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; -const viewer = ({ south, west, north, east }) => ({ - camera: { - computeViewRectangle: () => - Object.fromEntries( - Object.entries({ south, west, north, east }).map(([key, value]) => [ - key, - (value * Math.PI) / 180, - ]), - ), - }, - scene: { globe: { ellipsoid: {} } }, -}); +// A globe engine whose camera sees this rectangle (degrees). +const engineSeeing = ({ south, west, north, east }) => { + const engine = createFakeEngine(); + engine.fake.script( + 'viewRectangle', + Object.fromEntries( + Object.entries({ south, west, north, east }).map(([key, value]) => [ + key, + (value * Math.PI) / 180, + ]), + ), + ); + return engine; +}; test('local boxes snap outward to 0.1 degrees; wider views to whole degrees', () => { assert.deepEqual( @@ -87,7 +90,7 @@ test('intersections handle guard coverage, point boxes and antimeridian', () => test('viewport rectangles split at 180 degrees and reject overly large or missing views', () => { assert.deepEqual( wildlifeViewportBoxes( - viewer({ south: 10, west: 179, north: 12, east: -178 }), + engineSeeing({ south: 10, west: 179, north: 12, east: -178 }), ), [ { south: 10, west: 179, north: 12, east: 180 }, @@ -96,19 +99,19 @@ test('viewport rectangles split at 180 degrees and reject overly large or missin ); assert.deepEqual( wildlifeViewportBoxes( - viewer({ south: 47, west: -123, north: 48, east: -122 }), + engineSeeing({ south: 47, west: -123, north: 48, east: -122 }), ), [{ south: 47, west: -123, north: 48, east: -122 }], ); assert.equal( wildlifeViewportBoxes( - viewer({ south: -21, west: -10, north: 20, east: 10 }), + engineSeeing({ south: -21, west: -10, north: 20, east: 10 }), ), null, ); assert.equal( wildlifeViewportBoxes( - viewer({ south: 0, west: 170, north: 10, east: -140 }), + engineSeeing({ south: 0, west: 170, north: 10, east: -140 }), ), null, ); diff --git a/src/locations.js b/src/locations.js index 0aae1f67e..358dfe9dc 100644 --- a/src/locations.js +++ b/src/locations.js @@ -2,7 +2,6 @@ import { isUnavailableCapability } from './sources/capability.js'; import { createOverpassFeatureSource } from './sources/overpassFeatures.js'; import { applicationServices } from './services/application.js'; import * as geo from './geo/index.js'; -import { engineFromViewer } from './globe/cesium/engineFromViewer.js'; import { greatCircleKm } from './geoDistance.js'; import { viewportBias, @@ -31,14 +30,12 @@ export const GLOBE_VIEW = Object.freeze({ /** * Fly straight out to the full-earth globe view, keeping the current sub-camera * point centered so the user's continent stays in front of them. - * @param {import('./globe/contract.js').GlobeEngine | object} target The globe - * engine, or a viewer registered for one. + * @param {import('./globe/contract.js').GlobeEngine | null} engine * @param {{duration?: number, onComplete?: Function, onCancel?: Function}} options * @returns {{latitude: number, longitude: number, heightM: number} | null} * Null without an engine. */ -export function flyToGlobeView(target, options = {}) { - const engine = engineFromViewer(target); +export function flyToGlobeView(engine, options = {}) { if (!engine) return null; const { camera } = engine; const carto = camera.pose().cartographic; @@ -83,8 +80,7 @@ export const LOCATIONS = Object.entries(CITY_POIS).map(([id, city]) => ({ * Fly the camera to a landmark using lookAt-based targeting. * Guarantees the target is centered in viewport via flyToBoundingSphere + lookAt. * - * @param {import('./globe/contract.js').GlobeEngine | object} target The - * globe engine, or a viewer registered for one. + * @param {import('./globe/contract.js').GlobeEngine | null} engine * @param {number} lat - Latitude in degrees * @param {number} lon - Longitude in degrees * @param {object} options @@ -99,8 +95,7 @@ export const LOCATIONS = Object.entries(CITY_POIS).map(([id, city]) => ({ * @returns {{ targetPosition: {x: number, y: number, z: number} } | null} The * computed target for orbit use; null without an engine. */ -export function flyToLandmark(target, lat, lon, options = {}) { - const engine = engineFromViewer(target); +export function flyToLandmark(engine, lat, lon, options = {}) { if (!engine) return null; const { camera } = engine; cancelCameraArrival(engine); @@ -222,7 +217,7 @@ export function flyToLandmark(target, lat, lon, options = {}) { * Fly to a preset location by ID (uses the first POI as default). * Returns target position for orbit controller. */ -export function flyToPresetLocation(target, locationId, options = {}) { +export function flyToPresetLocation(engine, locationId, options = {}) { const city = CITY_POIS[locationId]; if (!city) return null; if ( @@ -230,7 +225,7 @@ export function flyToPresetLocation(target, locationId, options = {}) { !finitePositive(options.range) && city.viewBounds ) { - return flyToViewportBounds(engineFromViewer(target), city.viewBounds, { + return flyToViewportBounds(engine, city.viewBounds, { duration: options.duration, onStart: options.onStart, onComplete: options.onComplete, @@ -239,7 +234,7 @@ export function flyToPresetLocation(target, locationId, options = {}) { }); } const poi = city.pois[0]; - return flyToLandmark(target, poi.lat, poi.lon, { + return flyToLandmark(engine, poi.lat, poi.lon, { range: poi.alt, pitch: poi.pitch, heading: poi.heading || 0, @@ -254,11 +249,11 @@ export function flyToPresetLocation(target, locationId, options = {}) { * Fly to a specific POI within a city. * Returns target position for orbit controller. */ -export function flyToPOI(target, cityId, poiIndex, options = {}) { +export function flyToPOI(engine, cityId, poiIndex, options = {}) { const city = CITY_POIS[cityId]; if (!city || !city.pois[poiIndex]) return null; const poi = city.pois[poiIndex]; - return flyToLandmark(target, poi.lat, poi.lon, { + return flyToLandmark(engine, poi.lat, poi.lon, { range: poi.alt, pitch: poi.pitch, heading: poi.heading || 0, @@ -315,10 +310,8 @@ export const CANCELLED_SEARCH = Object.freeze({ cancelled: true }); * appropriate to the request. Countries and cities use their viewport by * default; precise landmarks/buildings use close landmark framing. */ -export async function searchAndFlyTo(target, query, options = {}) { +export async function searchAndFlyTo(engine, query, options = {}) { const { placeSearch = unavailablePlaceSearch, signal } = options; - // The globe engine, or (voice) a viewer registered for one. - const engine = engineFromViewer(target); if (!engine) return null; signal?.throwIfAborted(); const beforeFly = diff --git a/src/orbit.js b/src/orbit.js index 3806827ef..390da774a 100644 --- a/src/orbit.js +++ b/src/orbit.js @@ -1,5 +1,4 @@ import * as geo from './geo/index.js'; -import { engineFromViewer } from './globe/cesium/engineFromViewer.js'; import { holdContinuousRender, releaseContinuousRender, @@ -12,11 +11,10 @@ import { */ export class OrbitController { /** - * @param {import('./globe/contract.js').GlobeEngine | object} engine The - * globe engine, or a viewer registered for one. + * @param {import('./globe/contract.js').GlobeEngine | null} engine */ constructor(engine) { - this.engine = engineFromViewer(engine); + this.engine = engine ?? null; this.active = false; this.target = null; this.radius = 500; @@ -93,7 +91,7 @@ export class OrbitController { this._removeListener?.(); this._removeListener = null; } - this.engine = engine ? engineFromViewer(engine) : null; + this.engine = engine ?? null; } /** diff --git a/src/overlays/worldOverlayAllocation.worker.mjs b/src/overlays/worldOverlayAllocation.worker.mjs index f02cea38e..2707480d0 100644 --- a/src/overlays/worldOverlayAllocation.worker.mjs +++ b/src/overlays/worldOverlayAllocation.worker.mjs @@ -1371,7 +1371,7 @@ function main() { env.viewer.camera.viewMatrix[0] = 1 / 800_000; env.viewer.camera.viewMatrix[5] = 1 / 600_000; env.viewer.camera.positionCartographic.height = 2_500_000; - initDetection(env.viewer, [workload.detectionLayer], () => {}); + initDetection(env.engine, [workload.detectionLayer], () => {}); setDetectionTuning({ densityPct: 100, allocationStrategy: 'ELASTIC' }); setDetectionMode('DENSE'); } else if (workload.registrations) { diff --git a/src/scenes/director.js b/src/scenes/director.js index 69cf48c4f..4acda1a68 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -13,7 +13,6 @@ */ import { cancelCameraArrival } from '../data/cameraArrival.js'; -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; import { resolveCameraPose, resolveCameraMove } from '../director/camera.js'; import { createCameraMotion } from './cameraMotion.js'; import { createStateChannel } from '../app/stateChannel.js'; @@ -84,15 +83,15 @@ export class SceneDirector { * @param {Object} styleManager - Controls visual state (bloom, sharpen, HUD, detection, style presets) * @param {Object} dataManager - Manages data layer enable/disable and per-layer params * @param {Object} [options] `engine`: the globe engine whose camera the - * scene flights drive (defaults to the one registered for the viewer); the - * viewer still serves tracking, interactions and data packs. + * scene flights drive; the viewer still serves tracking, interactions and + * data packs. */ constructor( viewer, styleManager, dataManager, { - engine = engineFromViewer(viewer), + engine = null, isMapStackAvailable = () => false, scenePacks = createDefaultScenePacks(), dataPacks = {}, diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js index 1350719eb..0fbf11822 100644 --- a/src/services/meshFloorSampler.js +++ b/src/services/meshFloorSampler.js @@ -200,9 +200,8 @@ export function createMeshFloorSampler({ return { sampleMeshFloorCells, cachedGroundFloor, - /** Whether the engine of `target` (a scene or viewer) has its tiles - * loaded (street level's mesh sampler gate). */ - visibleTilesetLoaded: (target) => - engineFromViewer(target)?.surface.tilesLoaded() ?? false, + /** Whether the globe engine has its tiles loaded (street level's mesh + * sampler gate). */ + visibleTilesetLoaded: (engine) => engine?.surface.tilesLoaded() ?? false, }; } diff --git a/src/sharelink.js b/src/sharelink.js index 35dbda39b..390b24ba1 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -1,5 +1,4 @@ import * as geo from './geo/index.js'; -import { engineFromViewer } from './globe/cesium/engineFromViewer.js'; import { cesiumHeading, cesiumRoll } from './cameraAngles.js'; import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from './bloom.js'; import { @@ -65,8 +64,7 @@ const SHARE_STYLE_PARAM_REGISTRY = SHARE_VIEW_STYLE_PARAMS; export class ShareLinkManager { /** - * @param {import('./globe/contract.js').GlobeEngine | object} engine The - * globe engine, or a viewer registered for one. + * @param {import('./globe/contract.js').GlobeEngine | null} engine */ constructor( engine, @@ -77,7 +75,7 @@ export class ShareLinkManager { initialShareCode = null, } = {}, ) { - this.engine = engineFromViewer(engine); + this.engine = engine ?? null; // A host may hand over the share code to open instead of the address. this._initialShareCode = typeof initialShareCode === 'string' ? initialShareCode : null; @@ -165,7 +163,7 @@ export class ShareLinkManager { this._activeCameraFlight = null; flight.settle?.('superseded'); } - this.engine = engine ? engineFromViewer(engine) : null; + this.engine = engine ?? null; if (!this.engine) return; this._removeCameraChanged = this.engine.camera.changed.addEventListener( () => this._scheduleUpdate(), diff --git a/src/testSupport/aircraftViewerRenderer.mjs b/src/testSupport/aircraftViewerRenderer.mjs new file mode 100644 index 000000000..2fbe95f43 --- /dev/null +++ b/src/testSupport/aircraftViewerRenderer.mjs @@ -0,0 +1,256 @@ +// An aircraft renderer (src/layers/aircraft/render/contract.js) over a stub +// viewer and the test's own collections, for testing an aircraft layer's +// logic against Cesium-shaped fakes: sprites are what the test's +// billboardCollection.add returns, models load through +// Cesium.Model.fromGltfAsync (which tests stub to control readiness and +// failures) into its modelCollection, the tracked aircraft is an Entity in +// viewer.entities followed through viewer.trackedEntity (with Cesium's one +// exact tracked frame), trails are entity polylines. Not the application's display (render/display.js): that one is +// tested in display.test.mjs. + +import * as Cesium from 'cesium'; +import { createTrail } from '../data/trailRenderer.js'; +import { horizonOccluder } from '../data/iconOrientation.js'; +import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; +import { applyTrackedCameraFrame } from '../globe/cesium/follow.js'; + +const cesiumColor = (color) => + color instanceof Cesium.Color + ? color + : new Cesium.Color(color.red, color.green, color.blue, color.alpha); + +const cesiumNearFar = (scalar) => + scalar instanceof Cesium.NearFarScalar + ? scalar + : new Cesium.NearFarScalar( + scalar.near, + scalar.nearValue, + scalar.far, + scalar.farValue, + ); + +/** + * @param {{ viewer: object, billboardCollection?: object, + * modelCollection?: object, engine?: object, headingOffsetDeg?: number }} options + * `engine`: what the layer's selection dispatcher binds to (default: the + * engine registered for the viewer, if any). + */ +export function aircraftViewerRenderer({ + viewer, + billboardCollection = null, + modelCollection = null, + engine = null, + headingOffsetDeg = 0, +}) { + const billboards = billboardCollection; + const models = modelCollection; + const scratchHpr = new Cesium.HeadingPitchRoll(0, 0, 0); + const scratchSphere = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 1.0); + let cullingVolume = null; + const inFrustum = (position) => { + Cesium.Cartesian3.clone(position, scratchSphere.center); + return ( + cullingVolume.computeVisibility(scratchSphere) !== + Cesium.Intersect.OUTSIDE + ); + }; + const scratchSize = {}; + const load = (spec) => + Cesium.Model.fromGltfAsync({ + url: spec.url, + asynchronous: false, + minimumPixelSize: spec.minimumPixelSize, + scale: spec.scale, + color: spec.color, + colorBlendMode: Cesium.ColorBlendMode.MIX, + colorBlendAmount: spec.blendAmount, + id: spec.id, + }); + + const view = { + get camera() { + return viewer?.camera; + }, + get scene() { + return viewer?.scene; + }, + get surfaceOwner() { + return viewer; + }, + get surfaceScene() { + return viewer?.scene; + }, + frameNumber: () => viewer?.scene?.frameState?.frameNumber ?? -1, + viewportHeightPx: () => viewer.scene.canvas.clientHeight, + frustumTester() { + const camera = viewer.camera; + cullingVolume = camera.frustum.computeCullingVolume( + camera.positionWC, + camera.directionWC, + camera.upWC, + ); + return inFrustum; + }, + horizon: () => horizonOccluder(viewer.camera), + worldToWindow: (position, result) => + Cesium.SceneTransforms.worldToWindowCoordinates( + viewer.scene, + position, + result, + ), + requestRender: () => viewer?.scene?.requestRender?.(), + on(event, listener) { + const source = + event === 'moveEnd' + ? viewer?.camera?.moveEnd + : event === 'preRender' + ? viewer?.scene?.preRender + : event === 'preUpdate' + ? viewer?.scene?.preUpdate + : null; + return source ? source.addEventListener(listener) : null; + }, + isDestroyed: () => !viewer || viewer.isDestroyed?.() === true, + holdFrames() {}, + closeFrameLatch() {}, + releaseFrames() {}, + }; + + const renderer = { + get engine() { + return engine ?? engineFromViewer(viewer); + }, + view, + fleet: { + attach() {}, + detach() {}, + attached: () => !!billboards, + visible: () => !!billboards && billboards.show, + setVisible(visible) { + if (billboards) billboards.show = visible; + }, + add: (spec) => billboards.add(spec), + remove: (sprite) => billboards.remove(sprite), + restoreOrder() {}, + restoreOrderOnEnable() {}, + }, + models: { + live: () => !!models && !models.isDestroyed(), + setVisible(visible) { + if (models) models.show = visible; + }, + loadFleet: load, + loadTracked: load, + preload: (url) => + Cesium.Model.fromGltfAsync({ url, asynchronous: false }), + add: (model) => models.add(model), + remove(model) { + try { + models?.remove(model); + } catch { + /* gone */ + } + }, + destroy(model) { + try { + model.destroy(); + } catch { + /* gone */ + } + }, + setPose(model, position, headingDeg, offsetDeg = headingOffsetDeg) { + scratchHpr.heading = Cesium.Math.toRadians( + (headingDeg || 0) + offsetDeg, + ); + return Cesium.Transforms.headingPitchRollToFixedFrame( + position, + scratchHpr, + Cesium.Ellipsoid.WGS84, + undefined, + model.modelMatrix, + ); + }, + }, + tracked: { + create: (spec) => + viewer.entities.add({ + position: new Cesium.CallbackProperty(() => spec.position(), false), + trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, + billboard: { + image: spec.image, + width: spec.width, + height: spec.height, + scale: spec.scale, + color: new Cesium.CallbackProperty( + () => cesiumColor(spec.color()), + false, + ), + scaleByDistance: cesiumNearFar(spec.scaleByDistance), + alignedAxis: Cesium.Cartesian3.ZERO, + disableDepthTestDistance: spec.disableDepthTestDistance, + rotation: new Cesium.CallbackProperty(() => spec.rotation(), false), + }, + }), + remove: (handle) => viewer.entities.remove(handle), + isLive: (handle) => !!viewer?.entities?.contains?.(handle), + setImage(handle, image) { + if (handle?.billboard) handle.billboard.image = image; + }, + setViewFrom(handle, offset) { + handle.viewFrom = new Cesium.Cartesian3(offset.x, offset.y, offset.z); + }, + billboardSize(handle) { + const billboard = handle?.billboard; + if (!billboard) return null; + const time = viewer.clock?.currentTime; + scratchSize.width = billboard.width?.getValue(time); + scratchSize.height = billboard.height?.getValue(time); + scratchSize.scale = billboard.scale?.getValue(time); + scratchSize.scaleByDistance = billboard.scaleByDistance?.getValue(time); + return scratchSize; + }, + }, + follow: { + // The one exact tracked frame, as Cesium's engine follow applies it. + start(handle) { + viewer.camera?.cancelFlight?.(); + viewer.trackedEntity = handle; + return applyTrackedCameraFrame(viewer, handle, handle.viewFrom) || null; + }, + refocus: (handle) => renderer.follow.start(handle), + release() { + if (viewer) viewer.trackedEntity = undefined; + }, + isFollowing: (handle) => !!viewer && viewer.trackedEntity === handle, + current: () => viewer?.trackedEntity, + onChanged: (listener) => + viewer.trackedEntityChanged?.addEventListener(listener) ?? (() => {}), + }, + trails: { + create: ({ color, width }) => createTrail(viewer, { color, width }), + createHead({ id, show, positions, color, width }) { + if (!viewer?.entities) return null; + return viewer.entities.add({ + id, + show, + polyline: { + positions: new Cesium.CallbackProperty(() => positions(), false), + width, + material: Cesium.Color.fromCssColorString(color).withAlpha(0.9), + }, + }); + }, + removeHead(head) { + try { + if (head) viewer?.entities?.remove(head); + } catch { + /* already gone */ + } + }, + }, + surfaceExclusions: ({ sprites = [], models: modelHandles = [] }) => + [...sprites, ...modelHandles].filter(Boolean), + destroy() {}, + }; + return renderer; +} diff --git a/src/testSupport/cesiumMoversEngine.mjs b/src/testSupport/cesiumMoversEngine.mjs new file mode 100644 index 000000000..1a76d80af --- /dev/null +++ b/src/testSupport/cesiumMoversEngine.mjs @@ -0,0 +1,82 @@ +// The Cesium engine over a real Cesium camera on a stub scene +// (globe/cesium/testing/cameraViewer.mjs), set up for layers that draw +// through engine.movers, engine.lines and the follow: Cesium's own +// collections, billboards and polylines hold what the engine writes (they +// draw nothing); the atlas crop and the glTF load are faked (`loads` holds +// each fake Model, `ready` false until a test sets it); the viewer has the +// entities the follow's proxy goes in. + +import * as Cesium from 'cesium'; +import { createCameraViewer } from '../globe/cesium/testing/cameraViewer.mjs'; + +// Cesium's Material sorts uniform values with instanceof checks against these +// DOM classes; Node has none of them. +for (const name of [ + 'HTMLCanvasElement', + 'HTMLImageElement', + 'HTMLVideoElement', + 'ImageBitmap', + 'OffscreenCanvas', +]) + globalThis[name] ??= class {}; + +const items = (collection) => + Array.from({ length: collection.length }, (_, index) => + collection.get(index), + ); + +// What the Cesium engine reads and writes of a Model: its own model matrix +// (kept as given), a colour it copies. +function fakeModel(options) { + let color; + return { + options, + ready: false, + show: true, + modelMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), + get color() { + return color; + }, + set color(value) { + color = Cesium.Color.clone(value); + }, + isDestroyed: () => false, + destroy() { + this.destroyed = true; + }, + }; +} + +/** + * @returns {{ viewer: object, engine: object, frame: () => void, + * loads: object[], primitive: (id: string) => (object | undefined) }} + * `primitive(id)`: the Cesium primitive drawing a mover set or line. + */ +export function createCesiumMoversEngine() { + const loads = []; + const { viewer, engine, frame } = createCameraViewer({ + engineOptions: { + moverFactories: { + cropCells: async (atlas, keys) => + new Map(keys.map((key) => [key, { width: 8, height: 8, key }])), + loadModel: async (options) => { + const model = fakeModel(options); + loads.push(model); + return model; + }, + }, + }, + }); + viewer.entities = new Cesium.EntityCollection(); + const primitive = (id) => { + for (const root of items(viewer.scene.primitives)) + for (const child of items(root)) { + if (child.gevMoverSet === id) return child; + if (child instanceof Cesium.PrimitiveCollection) + for (const inner of items(child)) + if (inner.gevLine === id) return inner; + } + return undefined; + }; + return { viewer, engine, frame, loads, primitive }; +} diff --git a/src/testSupport/cockpitEngine.mjs b/src/testSupport/cockpitEngine.mjs new file mode 100644 index 000000000..0e32bda97 --- /dev/null +++ b/src/testSupport/cockpitEngine.mjs @@ -0,0 +1,47 @@ +// A globe engine for the Cockpit (src/ui/cockpitHost.js): the fake engine, +// and aircraft its follow rides the way it rides the aircraft display's +// tracked glyphs (src/layers/aircraft/render/display.js): the follow's +// `native` is the glyph handle, with `live`, `show`, `spec.position()` and +// `resumeAircraftFollow()`. +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; + +const VIEW_FROM = Object.freeze({ x: 0, y: -2400, z: 1650 }); + +/** + * @param {Parameters[0]} [options] + */ +export function createCockpitEngine(options) { + const engine = createFakeEngine(options); + const follow = (target) => + engine.camera.follow({ + native: target, + position: () => target.spec.position(), + viewFrom: VIEW_FROM, + }); + return { + engine, + /** + * A tracked aircraft glyph at `position` (a Cartesian3, or none); the + * other fields (gevTrackedId…) are copied on. + */ + aircraft({ position = null, ...fields } = {}) { + const target = { + live: true, + show: true, + spec: { position: () => position }, + ...fields, + }; + target.resumeAircraftFollow = () => { + follow(target); + }; + return target; + }, + /** Follow `target`, as its layer tracking it does: followChanged fires. */ + ride(target) { + follow(target); + return target; + }, + /** The followed aircraft, or null. */ + following: () => engine.camera.following()?.native ?? null, + }; +} diff --git a/src/testSupport/viewerEngine.mjs b/src/testSupport/viewerEngine.mjs index cb6197a04..3f17cd558 100644 --- a/src/testSupport/viewerEngine.mjs +++ b/src/testSupport/viewerEngine.mjs @@ -20,3 +20,19 @@ export function viewerEngine(viewer) { if (camera.setView) engine.camera.setView = camera.setView.bind(camera); return engine; } + +/** + * A globe engine whose follow is a viewer test double's tracked entity, as + * the Cesium engine reports one the app tracked: following() is + * `{ native: entity }` and followChanged is the viewer's + * trackedEntityChanged. + */ +export function trackingEngine(viewer) { + const engine = createFakeEngine(); + engine.camera.following = () => + viewer.trackedEntity ? { native: viewer.trackedEntity } : null; + Object.defineProperty(engine.camera, 'followChanged', { + value: viewer.trackedEntityChanged, + }); + return engine; +} diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 42ffd6c72..65a58ac31 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -35,7 +35,6 @@ import { } from './cyberSonarControls.js'; import * as geo from '../geo/index.js'; -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; import { cesiumHeading, cesiumRoll } from '../cameraAngles.js'; import { aircraftTrackingTarget } from '../cockpitTracking.js'; @@ -94,8 +93,7 @@ export class StyleManager extends ShellFacade { /** * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. * @param {object} [options] `engine`: the page's globe engine (the world - * overlay host, camera navigation, HUD and share links run on it; defaults - * to the one registered for the viewer). + * overlay host, camera navigation, HUD and share links run on it). * @param {object} [options] * @param {boolean} [options.localePicker=true] - false removes the language * picker, for a host that does not initialize Core's locale. @@ -103,7 +101,7 @@ export class StyleManager extends ShellFacade { constructor( viewer, { - engine = engineFromViewer(viewer), + engine = null, mapStackController = null, placeSearch, services, @@ -635,6 +633,7 @@ export class StyleManager extends ShellFacade { // Full-globe sun/moon ring on either engine, below the HUD/readout. this.celestialRing = new CelestialRing(viewer ?? engine, { + engine, enabled: false, store: useCelestialRingStore(applicationPinia()), onAutoDisable: () => @@ -716,7 +715,7 @@ export class StyleManager extends ShellFacade { async _attachScene({ viewer = null, - engine = engineFromViewer(viewer), + engine = null, mapStackController = null, services = {}, engineSwitch = true, @@ -863,7 +862,7 @@ export class StyleManager extends ShellFacade { if (changed('cctvLayer')) this._initCctvPanel(); if (changed('radioLayer', 'localAdsbLayer')) this._initRadioPanel(); if (changed('streetLevelLayer')) this._initStreetLevelPanel(); - if (changed('wildlifeSightingsLayer')) this._initWildlifeSightingCard(); + this._initWildlifeSightingCard(); if ( changed( 'radioLayer', @@ -1347,18 +1346,17 @@ export class StyleManager extends ShellFacade { return this._runExplicitNavigation('camera', () => focus(cameraId)); } - /** Keep one compact card anchored to the selected wildlife sighting. */ + /** Keep one compact card anchored to the selected wildlife sighting. The + * card follows the engine's canvas and camera, so each globe builds its own. */ _initWildlifeSightingCard() { this._wildlifeSightingCard?.destroy(); this._wildlifeSightingCard = null; const layer = this.services.wildlifeSightingsLayer; - // The layer draws on Cesium only; another engine gets its stand-in and - // no card (the card anchors to Cesium's canvas). - if (typeof layer?.subscribeSelection !== 'function' || !this.viewer) return; + if (typeof layer?.subscribeSelection !== 'function' || !this.engine) return; this._wildlifeSightingCard = createWildlifeSightingCard({ layer, ui: this._ui, - viewer: this.viewer, + engine: this.engine, }); } diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index a9acddd4f..785f75ac3 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -1,18 +1,12 @@ import * as geo from '../geo/index.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; -import { isGlobeEngine } from '../globe/contract.js'; -import { engineFromViewer } from '../globe/cesium/engineFromViewer.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 there is one, is read -// only for its clock, and for its tracked entity when the engine reports no -// follow: callers pass an engine and that viewer, or a viewer whose engine -// is registered (engineFromViewer). -const engineOf = (target) => engineFromViewer(target); -const trackerOf = (target, tracker) => - tracker ?? (target && !isGlobeEngine(target) ? target : null); +// app put on viewer.trackedEntity). The viewer, when callers pass one, is +// read only for its clock, and for its tracked entity when the engine +// reports no follow. // Cesium's EasingFunction.CUBIC_IN_OUT (tween.js Cubic.InOut). function cubicInOut(amount) { @@ -57,11 +51,9 @@ function normalizedHeading(heading) { /** * Return the world position under the center of the canvas, when available. - * @param {import('../globe/contract.js').GlobeEngine | object} target The - * globe engine, or a viewer registered for one. + * @param {import('../globe/contract.js').GlobeEngine | null} engine */ -export function pickViewTarget(target) { - const engine = engineOf(target); +export function pickViewTarget(engine) { if (!engine) return null; const { width, height } = engine.host.size(); if (!width || !height) return null; @@ -92,15 +84,12 @@ export function pickViewTarget(target) { /** * Describe the camera as an orbit around the current viewport center. - * @param {import('../globe/contract.js').GlobeEngine | object} target The - * globe engine, or a viewer registered for one. - * @param {object} [tracker] The viewer whose tracked entity is the orbit - * target (defaults to `target` when that is a viewer). + * @param {import('../globe/contract.js').GlobeEngine | null} engine + * @param {object | null} [viewer] The Cesium viewer whose tracked entity is + * the orbit target. */ -export function readCameraTargetFrame(target, tracker) { - const engine = engineOf(target); +export function readCameraTargetFrame(engine, viewer = null) { if (!engine) return null; - const viewer = trackerOf(target, tracker); const entity = followedEntity(engine, viewer); const follow = engineFollow(engine, viewer); const center = entity @@ -137,15 +126,12 @@ export function readCameraTargetFrame(target, tracker) { /** * Apply an orbit frame, then return the camera to the fixed-world frame. - * @param {import('../globe/contract.js').GlobeEngine | object} target The - * globe engine, or a viewer registered for one. + * @param {import('../globe/contract.js').GlobeEngine | null} engine * @param {object} frame From {@link readCameraTargetFrame}. - * @param {object} [tracker] As in readCameraTargetFrame. + * @param {object | null} [viewer] As in readCameraTargetFrame. */ -function setCameraTargetFrame(target, frame, tracker) { - const engine = engineOf(target); +function setCameraTargetFrame(engine, frame, viewer = null) { if (!engine || !frame?.target || !Number.isFinite(frame.range)) return false; - const viewer = trackerOf(target, tracker); 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 @@ -211,21 +197,21 @@ function frameIsTilted(frame) { } /** Toggle between a straight-down map and a useful oblique map angle. */ -export function toggleCameraTilt(target, tracker) { - const frame = readCameraTargetFrame(target, tracker); +export function toggleCameraTilt(engine, viewer = null) { + const frame = readCameraTargetFrame(engine, viewer); if (!frame) return false; const tilted = frameIsTilted(frame); const pitch = tilted ? STRAIGHT_DOWN_PITCH : OBLIQUE_PITCH; - return setCameraTargetFrame(target, { ...frame, pitch }, tracker) + return setCameraTargetFrame(engine, { ...frame, pitch }, viewer) ? { tilted: !tilted, pitch } : false; } /** Rotate around the viewport center until north is at the top. */ -export function resetCameraNorth(target, tracker) { - const frame = readCameraTargetFrame(target, tracker); +export function resetCameraNorth(engine, viewer = null) { + const frame = readCameraTargetFrame(engine, viewer); if (!frame) return false; - return setCameraTargetFrame(target, { ...frame, heading: 0 }, tracker); + return setCameraTargetFrame(engine, { ...frame, heading: 0 }, viewer); } function headingDegrees(engine) { @@ -234,17 +220,15 @@ function headingDegrees(engine) { /** * Ease an orbit without handing off the entity that owns the follow camera. - * @param {import('../globe/contract.js').GlobeEngine | object} target The - * globe engine, or a viewer registered for one. + * @param {import('../globe/contract.js').GlobeEngine | null} engine * @param {{ now?: () => number, duration?: number, tracker?: object }} [options] - * `tracker` as in readCameraTargetFrame. + * `tracker`: the viewer, as in readCameraTargetFrame. */ export function createCameraOrientationAnimator( - target, - { now = () => performance.now(), duration = 650, tracker } = {}, + engine, + { now = () => performance.now(), duration = 650, tracker = null } = {}, ) { - const engine = engineOf(target); - const viewer = trackerOf(target, tracker); + const viewer = tracker; let remove = null; let pending = null; const cancel = () => { @@ -310,12 +294,11 @@ export function createCameraOrientationAnimator( * runNavigation: Function, * showToast?: Function, * animationMs?: number, - * }} options `engine` defaults to the one registered for `viewer`; the viewer - * is otherwise only read for its clock and tracked entity. + * }} options The viewer is only read for its clock and tracked entity. */ export function bindCameraOrientationControls({ viewer = null, - engine = engineOf(viewer), + engine = null, publish, runNavigation, showToast, diff --git a/src/ui/cameraOrientationControls.test.mjs b/src/ui/cameraOrientationControls.test.mjs index 6c4ce3bf1..a3a0b93f6 100644 --- a/src/ui/cameraOrientationControls.test.mjs +++ b/src/ui/cameraOrientationControls.test.mjs @@ -15,14 +15,13 @@ import { NavigationController } from './navigationController.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import * as geo from '../geo/index.js'; import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; -import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js'; -// The controls run on the globe engine; these viewer doubles get the Cesium -// engine registered for them, as the app's scene engine is. A Viewer's -// canvas is its scene's. +// The controls run on the globe engine; these viewer doubles carry the +// Cesium engine built over them (`viewer.engine`), as the app's scene engine +// is. A Viewer's canvas is its scene's. function withEngine(viewer) { viewer.canvas = viewer.scene.canvas; - registerViewerEngine(viewer, createCesiumEngine(viewer)); + viewer.engine = createCesiumEngine(viewer); return viewer; } @@ -178,7 +177,7 @@ test('a heading written into a real camera reads back as the same heading', () = ); viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); - const frame = readCameraTargetFrame(viewer); + const frame = readCameraTargetFrame(viewer.engine, viewer); assert.ok(frame, `no frame at ${headingDeg}/${pitchDeg}`); const readDeg = Cesium.Math.toDegrees(frame.heading); assert.ok( @@ -208,11 +207,11 @@ test('north-up really points a real camera north from every quadrant', () => { viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); assert.equal( - resetCameraNorth(viewer), + resetCameraNorth(viewer.engine, viewer), true, `reset failed at ${headingDeg}`, ); - const after = readCameraTargetFrame(viewer); + const after = readCameraTargetFrame(viewer.engine, viewer); assert.ok(after, `no frame after reset at ${headingDeg}`); const readDeg = Cesium.Math.toDegrees(after.heading); assert.ok( @@ -242,9 +241,9 @@ test('tilt moves a real camera between the two pitches and keeps target and rang ); viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); - const first = toggleCameraTilt(viewer); + const first = toggleCameraTilt(viewer.engine, viewer); assert.deepEqual(first, { tilted: true, pitch: OBLIQUE_PITCH }); - const tilted = readCameraTargetFrame(viewer); + const tilted = readCameraTargetFrame(viewer.engine, viewer); assert.ok(Math.abs(tilted.pitch - OBLIQUE_PITCH) < 1e-6); assert.ok(Math.abs(tilted.range - 3000) < 1, `range ${tilted.range}`); assert.ok( @@ -276,9 +275,9 @@ test('tilt moves a real camera between the two pitches and keeps target and rang `camera points ${offBy.toFixed(2)} deg away from its target`, ); - const second = toggleCameraTilt(viewer); + const second = toggleCameraTilt(viewer.engine, viewer); assert.deepEqual(second, { tilted: false, pitch: STRAIGHT_DOWN_PITCH }); - const down = readCameraTargetFrame(viewer); + const down = readCameraTargetFrame(viewer.engine, viewer); assert.ok(Math.abs(down.pitch - STRAIGHT_DOWN_PITCH) < 1e-6); assert.ok(Math.abs(down.range - 3000) < 1); }); @@ -291,19 +290,19 @@ test('tilt alternates at globe range, not only at map range', () => { const { viewer } = createRealViewer(AUSTIN_GROUND); orbit(viewer, AUSTIN_GROUND, 0, STRAIGHT_DOWN_PITCH, range); - const first = toggleCameraTilt(viewer); + const first = toggleCameraTilt(viewer.engine, viewer); assert.deepEqual( first, { tilted: true, pitch: OBLIQUE_PITCH }, `first click at ${range} m must tilt`, ); - const second = toggleCameraTilt(viewer); + const second = toggleCameraTilt(viewer.engine, viewer); assert.deepEqual( second, { tilted: false, pitch: STRAIGHT_DOWN_PITCH }, `second click at ${range} m must level (camera pitch reads ${Cesium.Math.toDegrees(viewer.camera.pitch).toFixed(1)} deg)`, ); - const third = toggleCameraTilt(viewer); + const third = toggleCameraTilt(viewer.engine, viewer); assert.deepEqual( third, { tilted: true, pitch: OBLIQUE_PITCH }, @@ -319,6 +318,7 @@ test('the button shows the state the next click will produce, at every range', ( const bar = recorder(); const controls = bindCameraOrientationControls({ viewer, + engine: viewer.engine, publish: bar.publish, runNavigation: (_noun, navigate) => navigate(), // No frame loop drives this fixture: apply each change at once. @@ -353,6 +353,7 @@ test('the needle follows a rotation too small for the camera-changed threshold', const bar = recorder(); const controls = bindCameraOrientationControls({ viewer, + engine: viewer.engine, publish: bar.publish, runNavigation: (_noun, navigate) => navigate(), }); @@ -383,6 +384,7 @@ test('north-up indicator follows real heading including the 360-degree seam', () const bar = recorder(); const controls = bindCameraOrientationControls({ viewer, + engine: viewer.engine, publish: bar.publish, runNavigation: (_noun, navigate) => navigate(), }); @@ -409,6 +411,7 @@ test('a settled camera publishes no change, and teardown releases every signal', const moveEndBefore = viewer.camera.moveEnd.numberOfListeners; const controls = bindCameraOrientationControls({ viewer, + engine: viewer.engine, publish: bar.publish, runNavigation: (_noun, navigate) => navigate(), }); @@ -451,9 +454,9 @@ test('a sky-facing camera yields no frame and both actions decline', () => { camera.pickEllipsoid = () => undefined; const viewer = withEngine({ camera, scene }); - assert.equal(readCameraTargetFrame(viewer), null); - assert.equal(toggleCameraTilt(viewer), false); - assert.equal(resetCameraNorth(viewer), false); + assert.equal(readCameraTargetFrame(viewer.engine, viewer), null); + assert.equal(toggleCameraTilt(viewer.engine, viewer), false); + assert.equal(resetCameraNorth(viewer.engine, viewer), false); }); test('center picking prefers terrain over the ellipsoid and falls through an invalid depth hit', () => { @@ -464,14 +467,14 @@ test('center picking prefers terrain over the ellipsoid and falls through an inv viewer.scene.globe.pick = () => terrain; viewer.camera.pickEllipsoid = () => sea; sameXYZ( - pickViewTarget(viewer), + pickViewTarget(viewer.engine), terrain, 'the rendered ground wins over sea level', ); viewer.scene.globe.pick = () => null; sameXYZ( - pickViewTarget(viewer), + pickViewTarget(viewer.engine), sea, 'the ellipsoid still answers when nothing is under the cursor', ); @@ -479,7 +482,7 @@ test('center picking prefers terrain over the ellipsoid and falls through an inv test('target frame identifies a straight-down camera in local ENU space', () => { const { viewer } = createViewer(); - const frame = readCameraTargetFrame(viewer); + const frame = readCameraTargetFrame(viewer.engine, viewer); assert.ok(frame); assert.ok(Math.abs(frame.pitch - Cesium.Math.toRadians(-90)) < 0.001); assert.ok(frame.range > 999 && frame.range < 1_001); @@ -488,8 +491,8 @@ test('target frame identifies a straight-down camera in local ENU space', () => test('tilt preserves the picked target and range while choosing oblique pitch', () => { const { viewer, calls, target } = createViewer(); - const before = readCameraTargetFrame(viewer); - const result = toggleCameraTilt(viewer); + const before = readCameraTargetFrame(viewer.engine, viewer); + const result = toggleCameraTilt(viewer.engine, viewer); const lookAt = calls.find((call) => call.type === 'lookAt'); assert.deepEqual(result, { tilted: true, pitch: OBLIQUE_PITCH }); sameXYZ(lookAt.target, target); @@ -501,8 +504,8 @@ test('tilt preserves the picked target and range while choosing oblique pitch', test('north-up preserves target pitch and range', () => { const { viewer, calls } = createViewer(); - const before = readCameraTargetFrame(viewer); - assert.equal(resetCameraNorth(viewer), true); + const before = readCameraTargetFrame(viewer.engine, viewer); + assert.equal(resetCameraNorth(viewer.engine, viewer), true); const lookAt = calls.find((call) => call.type === 'lookAt'); assert.equal(lookAt.offset.heading, 0); assert.equal(lookAt.offset.pitch, before.pitch); @@ -516,6 +519,7 @@ test('bindings route both controls and release every listener on destroy', () => const toasts = []; const controls = bindCameraOrientationControls({ viewer, + engine: viewer.engine, publish: bar.publish, runNavigation(noun, navigate) { navigations.push(noun); @@ -574,12 +578,13 @@ for (const [label, altitude, range] of [ ), ); let time = 0; - const animator = createCameraOrientationAnimator(viewer, { + const animator = createCameraOrientationAnimator(viewer.engine, { + tracker: viewer, now: () => time, duration: 1000, }); for (const pitch of [STRAIGHT_DOWN_PITCH, OBLIQUE_PITCH]) { - const frame = readCameraTargetFrame(viewer); + const frame = readCameraTargetFrame(viewer.engine, viewer); animator.animate(frame, { heading: 0, pitch }); for (const step of [0, 100, 500, 900, 1000]) { time = (pitch === STRAIGHT_DOWN_PITCH ? 0 : 1000) + step; @@ -599,7 +604,7 @@ for (const [label, altitude, range] of [ 1e-8, ), ); - const actual = readCameraTargetFrame(viewer); + const actual = readCameraTargetFrame(viewer.engine, viewer); const eased = Cesium.EasingFunction.CUBIC_IN_OUT(step / 1000); assert.ok( Math.abs(actual.pitch - Cesium.Math.lerp(frame.pitch, pitch, eased)) < @@ -612,15 +617,20 @@ for (const [label, altitude, range] of [ target = Cesium.Cartesian3.fromDegrees(-96, 31, altitude); follow.update(viewer.clock.currentTime); assert.equal(viewer.trackedEntity, entity); - assert.ok(Math.abs(readCameraTargetFrame(viewer).range - range) < 1e-5); + assert.ok( + Math.abs(readCameraTargetFrame(viewer.engine, viewer).range - range) < + 1e-5, + ); }); } test('orientation animation cancels on replacement and explicit cancellation', () => { const { viewer } = createRealViewer(Cesium.Cartesian3.fromDegrees(0, 0)); orbit(viewer, Cesium.Cartesian3.fromDegrees(0, 0), 90, OBLIQUE_PITCH, 1000); - const animator = createCameraOrientationAnimator(viewer); - const frame = readCameraTargetFrame(viewer); + const animator = createCameraOrientationAnimator(viewer.engine, { + tracker: viewer, + }); + const frame = readCameraTargetFrame(viewer.engine, viewer); animator.animate(frame, { heading: 0, pitch: STRAIGHT_DOWN_PITCH }); animator.animate(frame, { heading: 0, pitch: OBLIQUE_PITCH }); assert.equal(viewer.scene.preUpdate.numberOfListeners, 1); @@ -704,6 +714,7 @@ test('rapid tilt presses reverse direction and north-up keeps the requested tilt let controls; controls = bindCameraOrientationControls({ viewer, + engine: viewer.engine, publish: bar.publish, runNavigation: (_noun, navigate) => { controls.cancel(); @@ -718,7 +729,7 @@ test('rapid tilt presses reverse direction and north-up keeps the requested tilt assert.equal(viewer.scene.preUpdate.numberOfListeners, 1); time = 650; viewer.scene.preUpdate.raiseEvent(); - const frame = readCameraTargetFrame(viewer); + const frame = readCameraTargetFrame(viewer.engine, viewer); assert.ok(Math.abs(frame.pitch - STRAIGHT_DOWN_PITCH) < 1e-6); assert.ok(Math.abs(frame.heading) < 1e-6); controls.destroy(); diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index 3295ebc34..2242c5632 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -33,7 +33,7 @@ export function update() { // Entry availability changes on a human timescale (tracking start/stop, // info arriving after a poll) — polling it every rendered frame ran // readAircraftInfo() + DOM pokes at display rate in plain map mode. - // 250 ms keeps the chip imperceptibly fresh; trackedEntityChanged still + // 250 ms keeps the chip imperceptibly fresh; the follow change still // fires syncEntry immediately on the events that matter. (perf item 9) const nowMs = performance.now(); if (nowMs - (this._lastEntrySyncMs || 0) >= 250) { diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 6ae5d601a..e1f03044a 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -74,11 +74,17 @@ import * as geo from '../geo/index.js'; import { cockpitHost } from './cockpitHost.js'; export class CockpitViewController { + /** + * @param {import('../globe/contract.js').GlobeEngine} engine The scene's + * globe engine: its camera, frames, input and follow (cockpitHost.js). + * @param {object} options `viewer`: Cesium's Viewer on the Cesium engine, + * whose stock double-click the Cockpit's goes ahead of. + */ constructor( - viewer, + engine, { services, - engine = null, + viewer = null, onVisionChange = null, getInitialVisionMode = null, onCameraTakeover = null, @@ -87,11 +93,10 @@ export class CockpitViewController { onEntered = null, onExited = null, onRide = null, - restoreTrackingFrame = null, publish = {}, } = {}, ) { - this.host = cockpitHost(viewer, engine); + this.host = cockpitHost(engine, { viewer }); this.services = services; this.destroyed = false; this.disposed = false; @@ -127,10 +132,6 @@ export class CockpitViewController { this.onEntered = onEntered; this.onExited = onExited; this.onRide = onRide; - this.restoreTrackingFrame = - typeof restoreTrackingFrame === 'function' - ? restoreTrackingFrame - : () => false; this.isEntryAllowed = typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true; this.isUtilityDisclosureOpen = diff --git a/src/ui/cockpitController.test.mjs b/src/ui/cockpitController.test.mjs index 25d5a6e6c..fa31a3a8f 100644 --- a/src/ui/cockpitController.test.mjs +++ b/src/ui/cockpitController.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { CockpitViewController } from './cockpitController.js'; import { COCKPIT_MIN_GROUND_CLEARANCE_M } from './cockpitPresentation.js'; import * as geo from '../geo/index.js'; +import { createCockpitEngine } from '../testSupport/cockpitEngine.mjs'; function environment() { const original = { document: globalThis.document, window: globalThis.window }; @@ -14,25 +15,18 @@ function environment() { const window = Object.assign(new EventTarget(), { clearTimeout, setTimeout }); globalThis.document = document; globalThis.window = window; - const callbacks = new Set(); - const event = { - addEventListener(fn) { - callbacks.add(fn); - return () => callbacks.delete(fn); - }, - }; - const viewer = { - scene: { - preUpdate: event, - screenSpaceCameraController: { enableInputs: false }, - }, - trackedEntityChanged: event, - entities: { contains: () => false }, - }; + const cockpit = createCockpitEngine(); + const { engine } = cockpit; + // The Cockpit's frame and follow subscriptions. + const subscriptions = () => + engine.frames.preUpdate.numberOfListeners + + engine.camera.followChanged.numberOfListeners; return { - viewer, - callbacks, + engine, + cockpit, + subscriptions, restore() { + engine.destroy(); Object.assign(globalThis, original); }, }; @@ -41,11 +35,20 @@ function environment() { test('Cockpit subscriptions are released once and retained actions cannot restart it', () => { const env = environment(); try { - const owner = new CockpitViewController(env.viewer, { services: {} }); - assert.equal(env.callbacks.size, 2); + const before = env.subscriptions(); + const owner = new CockpitViewController(env.engine, { services: {} }); + assert.equal(env.subscriptions(), before + 2); + let doubleClicks = 0; + owner.onSceneDoubleClick = () => { + doubleClicks++; + }; + env.engine.fake.input('doubleClick', { position: { x: 1, y: 1 } }); + assert.equal(doubleClicks, 1); owner.dispose(); owner.dispose(); - assert.equal(env.callbacks.size, 0); + assert.equal(env.subscriptions(), before); + env.engine.fake.input('doubleClick', { position: { x: 1, y: 1 } }); + assert.equal(doubleClicks, 1, 'the double-click route is released'); assert.equal(owner.enter(), false); assert.equal(owner.navigateContext(1), false); assert.equal(owner.toggleTrackedTr3b(), false); @@ -60,18 +63,19 @@ test('disposing an active Cockpit releases the supplied render owner without ret const env = environment(); try { const released = []; - const owner = new CockpitViewController(env.viewer, { + const owner = new CockpitViewController(env.engine, { services: { releaseContinuousRender: (reason) => released.push(reason) }, }); - owner.active = true; + const ridden = env.cockpit.aircraft(); + env.engine.input.setCameraInputs(false); + ridden.show = false; + Object.assign(owner, { active: true, trackedEntity: ridden }); owner.dispose(); owner.dispose(); assert.deepEqual(released, ['cockpit']); - assert.equal( - env.viewer.scene.screenSpaceCameraController.enableInputs, - true, - ); - assert.equal(env.viewer.trackedEntity, undefined); + assert.equal(env.engine.fake.cameraInputsEnabled, true); + assert.equal(ridden.show, true, 'the ridden glyph is shown again'); + assert.equal(env.engine.camera.following(), null, 'and not followed'); assert.equal(owner.active, false); } finally { env.restore(); @@ -91,15 +95,17 @@ test('aircraft identity is read from the supplied layer instances', () => { layerId: 'military', callsign: 'MILITARY', }; - const owner = new CockpitViewController(env.viewer, { + const owner = new CockpitViewController(env.engine, { services: { flightsLayer: { getTrackedInfo: () => civilian }, militaryFlightsLayer: { getTrackedInfo: () => military }, + // A follow change syncs the entry and its TR-3B chip. + isTr3b: () => false, }, }); - env.viewer.trackedEntity = { gevTrackedId: 'military:def456' }; + env.cockpit.ride(env.cockpit.aircraft({ gevTrackedId: 'military:def456' })); assert.deepEqual(owner.readAircraftInfo(), military); - env.viewer.trackedEntity = { gevTrackedId: 'flights:abc123' }; + env.cockpit.ride(env.cockpit.aircraft({ gevTrackedId: 'flights:abc123' })); assert.deepEqual(owner.readAircraftInfo(), civilian); owner.dispose(); } finally { @@ -112,7 +118,7 @@ test('an obsolete regional briefing cannot publish after Cockpit disposal', asyn try { let finish; let signal; - const owner = new CockpitViewController(env.viewer, { + const owner = new CockpitViewController(env.engine, { services: { regionalDistanceM: () => Infinity, fetchRegionalBrief: (_lat, _lon, options) => { @@ -148,14 +154,15 @@ test('stopping Cockpit revokes camera actions before deferred disposal releases const env = environment(); try { const released = []; - const owner = new CockpitViewController(env.viewer, { + const before = env.subscriptions(); + const owner = new CockpitViewController(env.engine, { services: { releaseContinuousRender: (reason) => released.push(reason), }, }); owner.active = true; owner.stop(); - assert.equal(env.callbacks.size, 0); + assert.equal(env.subscriptions(), before); assert.equal(owner.update(), false); assert.equal(owner.navigateContext(1), false); assert.deepEqual(released, []); @@ -174,13 +181,12 @@ test('stopped Cockpit cannot rearm briefing rotation or request a fresh region', let next = 0; let fetches = 0; let paints = 0; - env.viewer.scene.screenSpaceCameraController.enableInputs = true; window.setTimeout = (callback) => { frames.set(++next, callback); return next; }; window.clearTimeout = (id) => frames.delete(id); - const owner = new CockpitViewController(env.viewer, { + const owner = new CockpitViewController(env.engine, { services: { regionalDistanceM: () => Infinity, fetchRegionalBrief: () => { @@ -223,7 +229,7 @@ test('the weather toggle flips from its own state, however fast it is pressed', requested.push(event.detail.enabled), ); const published = []; - const owner = new CockpitViewController(env.viewer, { + const owner = new CockpitViewController(env.engine, { services: {}, publish: { context: (view) => published.push(view.weather) }, }); @@ -261,23 +267,9 @@ test('the cockpit eye stays above the engine surface when the aircraft reads bel place.latitude, 107, ); - const views = []; - Object.assign(env.viewer, { - clock: { currentTime: 0 }, - camera: { - setView: ({ destination }) => - views.push(geo.cartographic.fromCartesian(destination).height), - positionCartographic: { ...place, height: 130 }, - }, - entities: { contains: () => true }, - }); - env.viewer.scene.globe = { getHeight: () => SURFACE_M }; - const entity = { - position: { - getValue: (time, result) => geo.cartesian3.clone(aircraft, result), - }, - }; - const owner = new CockpitViewController(env.viewer, { + env.engine.fake.script('terrainHeight', SURFACE_M); + const entity = env.cockpit.aircraft({ position: aircraft }); + const owner = new CockpitViewController(env.engine, { services: { cachedGroundFloor: () => undefined, GROUND_FLOOR_LIFT_M: 1.5, @@ -297,6 +289,12 @@ test('the cockpit eye stays above the engine surface when the aircraft reads bel updateHud() {}, }); owner.update(); + const views = env.engine.fake + .callsOf('camera.setView') + .map( + ({ args: [{ destination }] }) => + geo.cartographic.fromCartesian(destination).height, + ); assert.equal(views.length, 1); assert.ok( views[0] >= SURFACE_M + COCKPIT_MIN_GROUND_CLEARANCE_M - 1e-6, diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index c168fba29..869fee18f 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -40,12 +40,14 @@ export class CockpitCoordinator { operations, ); this._cockpitDisplayPortal = null; + // The Cockpit rides the engine's follow; a shell with no globe yet has + // none. this.cockpitView = - viewer || (engine?.frames?.preUpdate && engine?.camera?.followChanged) - ? this._createCockpitView(viewer, services) + engine?.frames?.preUpdate && engine?.camera?.followChanged + ? this._createCockpitView(services) : null; } - _createCockpitView(viewer, services) { + _createCockpitView(services) { const { flightsLayer, militaryFlightsLayer, @@ -64,8 +66,9 @@ export class CockpitCoordinator { regionalDistanceM, weatherCodeLabel, } = services; - return new CockpitViewController(viewer, { - engine: this.engine, + return new CockpitViewController(this.engine, { + // Cesium's stock double-click goes after the Cockpit's. + viewer: this.viewer, services: { flightsLayer, militaryFlightsLayer, @@ -107,18 +110,6 @@ export class CockpitCoordinator { onEntered: () => this.enterPanels(), onExited: () => this.exitPanels(), onRide: (layerId) => this._adoptRiddenLayer(layerId), - restoreTrackingFrame: (entity) => { - const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split( - ':', - ); - const trackedId = idParts.join(':'); - if (!trackedId) return false; - if (layerId === 'flights') - return flightsLayer.refocusTrackedById?.(trackedId) === true; - if (layerId === 'military') - return militaryFlightsLayer.refocusTrackedById?.(trackedId) === true; - return false; - }, }); } get _dataManager() { @@ -178,10 +169,10 @@ export class CockpitCoordinator { const active = Boolean(this.cockpitView?.active); const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.()); // "Could Cockpit be ENTERED right now" — so it is false while already - // inside, unconditionally. Cockpit takes the entity off - // `viewer.trackedEntity` on entry and NEXT puts one back, which made this - // flip true/false between calls while `active` stayed true; readers - // (including the voice model) read that as a broken half-entered state. + // inside, unconditionally. Cockpit releases the engine's follow on entry + // and NEXT starts another, which made this flip true/false between + // calls while `active` stayed true; readers (including the voice model) + // read that as a broken half-entered state. const entryAllowed = !active && Boolean(gateOpen && info && this.cockpitView?.host.current()); // Entry needs only the ridden aircraft's own layer: with one tracked, diff --git a/src/ui/cockpitEntryGate.test.mjs b/src/ui/cockpitEntryGate.test.mjs index 834f1866f..11869cc9f 100644 --- a/src/ui/cockpitEntryGate.test.mjs +++ b/src/ui/cockpitEntryGate.test.mjs @@ -11,6 +11,7 @@ import { createApp, defineComponent, h, nextTick } from 'vue'; import { DataLayerManager } from '../data/manager.js'; import { createDependencies } from '../layers/awareness/dependencies.js'; import { CockpitCoordinator } from './cockpitCoordinator.js'; +import { createCockpitEngine } from '../testSupport/cockpitEngine.mjs'; import { ContextControls } from './contextControls.js'; import { _handleContextLayerChange } from './contextLayerChanges.js'; import { useCockpitStore } from './stores/cockpit.js'; @@ -60,29 +61,17 @@ function fixture( tracked?.layerId === layerId && dataManager.isEnabled(layerId) ? tracked.info : null; - const entityFor = (subject) => ({ - position: { getValue: () => null }, - show: true, - gevTrackedId: subject ? `${subject.layerId}:${subject.info.icao24}` : '', - }); - const entities = new Set(); - const entity = entityFor(tracked); - entities.add(entity); - const listeners = () => ({ addEventListener: () => () => {} }); - const viewer = { - trackedEntity: tracked ? entity : undefined, - clock: { currentTime: null }, - camera: { cancelFlight() {}, frustum: { fov: 1, aspectRatio: 1 } }, - entities: { contains: (candidate) => entities.has(candidate) }, - scene: { - preUpdate: listeners(), - screenSpaceCameraController: { enableInputs: true }, - }, - trackedEntityChanged: listeners(), - }; + // The tracked aircraft is the engine follow's glyph, as its layer draws it. + const globe = createCockpitEngine(); + const { engine } = globe; + const aircraftFor = (subject) => + globe.aircraft({ + gevTrackedId: `${subject.layerId}:${subject.info.icao24}`, + }); + if (tracked) globe.ride(aircraftFor(tracked)); const context = { mode: contextMode, changing }; const coordinator = new CockpitCoordinator({ - viewer, + engine, services: { flightsLayer: { getTrackedInfo: trackedFrom('flights') }, militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') }, @@ -119,6 +108,7 @@ function fixture( }); t.after(() => { coordinator.stop(); + engine.destroy(); app.unmount(); document.body.classList.remove('cockpit-mode'); }); @@ -141,8 +131,7 @@ function fixture( /** Track another aircraft, as Contacts navigation does mid-session. */ const retrack = (subject) => { tracked = subject; - viewer.trackedEntity = entityFor(subject); - entities.add(viewer.trackedEntity); + return globe.ride(aircraftFor(subject)); }; const $ = (selector) => document.querySelector(selector); return { $, coordinator, cockpit, store, layers, disable, retrack, pinia }; @@ -429,8 +418,10 @@ test('a ride handed to an aircraft on a layer Contacts turned on keeps that laye await settle(); assert.equal(cockpit.enter(), true); // NEXT hands the view to a military aircraft; Contacts turned its layer on. - retrack({ layerId: 'military', info: MILITARY }); - assert.equal(cockpit._adoptTrackedEntity(performance.now()), true); + // The Cockpit adopts it when the engine's follow changes to it. + const military = retrack({ layerId: 'military', info: MILITARY }); + assert.equal(cockpit.trackedEntity, military); + assert.equal(military.show, false, 'the new ride is not drawn either'); assert.equal(cockpit.readSubjectLayerId(), 'military'); await manager.setEnabled('ais-live-vessels', false, { origin: 'user' }); await settle(); diff --git a/src/ui/cockpitHost.js b/src/ui/cockpitHost.js index 3b90743fc..8fa066a9a 100644 --- a/src/ui/cockpitHost.js +++ b/src/ui/cockpitHost.js @@ -1,36 +1,15 @@ -// Camera and aircraft ownership used by the shared first-person controller. +// Camera and aircraft ownership used by the shared first-person controller, +// on the globe contract: the ridden aircraft is the engine follow's target, +// the aircraft display's tracked glyph handle. import { routeViewerDoubleClick } from '../globe/cesium/doubleClick.js'; -export function cockpitHost(viewer, engine) { - if (viewer) { - return { - preUpdate: viewer.scene.preUpdate, - followChanged: viewer.trackedEntityChanged, - followed: () => viewer.trackedEntity, - current: () => - viewer.trackedEntity?.position ? viewer.trackedEntity : null, - contains: (entity) => viewer.entities.contains(entity), - position: (entity, result) => - entity.position.getValue(viewer.clock.currentTime, result), - release: () => { - viewer.trackedEntity = undefined; - }, - restore: (entity, refocus) => { - viewer.trackedEntity = entity; - refocus(entity); - }, - camera: viewer.camera, - cartographic: () => viewer.camera.positionCartographic, - terrainHeight: (place) => viewer.scene.globe?.getHeight(place), - surface: viewer.scene, - inputs: (enabled) => { - viewer.scene.screenSpaceCameraController.enableInputs = enabled; - }, - canvasPosition: (world) => - viewer.scene.cartesianToCanvasCoordinates(world) ?? null, - routeDoubleClick: (handle) => routeViewerDoubleClick(viewer, handle), - }; - } +/** + * @param {import('../globe/contract.js').GlobeEngine} engine + * @param {{ viewer?: object|null }} [options] `viewer`: Cesium's Viewer when + * the engine is Cesium. Its stock double-click would drop the follow, so + * the Cockpit's goes first; Navara has none to route around. + */ +export function cockpitHost(engine, { viewer = null } = {}) { const aircraft = () => { const target = engine.camera.following()?.native; return target?.resumeAircraftFollow ? target : null; @@ -50,8 +29,9 @@ export function cockpitHost(viewer, engine) { surface: engine, inputs: (enabled) => engine.input.setCameraInputs(enabled), canvasPosition: (world) => engine.projector.worldToWindow(world) ?? null, - // Navara has no stock double-click to route around. routeDoubleClick: (handle) => - engine.input.on('doubleClick', (event) => handle(event.position)), + viewer + ? routeViewerDoubleClick(viewer, handle) + : engine.input.on('doubleClick', (event) => handle(event.position)), }; } diff --git a/src/ui/cockpitPresence.test.mjs b/src/ui/cockpitPresence.test.mjs index f9ddcd24b..3c151b8d0 100644 --- a/src/ui/cockpitPresence.test.mjs +++ b/src/ui/cockpitPresence.test.mjs @@ -7,6 +7,7 @@ import assert from 'node:assert/strict'; import { createPinia } from 'pinia'; import { createApp, defineComponent, h, nextTick } from 'vue'; import { CockpitViewController } from './cockpitController.js'; +import { createCockpitEngine } from '../testSupport/cockpitEngine.mjs'; import { useCockpitStore } from './stores/cockpit.js'; import CockpitView from './views/CockpitView.vue'; import ContextView from './views/ContextView.vue'; @@ -39,19 +40,10 @@ function fixture(t) { }; } const converted = new Set(); - const listeners = () => ({ addEventListener: () => () => {} }); - const entity = { position: {}, show: true }; - const viewer = { - trackedEntity: entity, - camera: { cancelFlight() {} }, - entities: { contains: (candidate) => candidate === entity }, - scene: { - preUpdate: listeners(), - screenSpaceCameraController: { enableInputs: true }, - }, - trackedEntityChanged: listeners(), - }; - const cockpit = new CockpitViewController(viewer, { + const globe = createCockpitEngine(); + const { engine } = globe; + const aircraft = globe.ride(globe.aircraft()); + const cockpit = new CockpitViewController(engine, { services: { flightsLayer: { getTrackedInfo: () => INFO, refreshTr3b() {} }, militaryFlightsLayer: { getTrackedInfo: () => null }, @@ -79,11 +71,12 @@ function fixture(t) { }); t.after(() => { cockpit.stop(); + engine.destroy(); app.unmount(); document.body.classList.remove('cockpit-mode'); }); const $ = (selector) => document.querySelector(selector); - return { $, cockpit, store, converted, hiddenFocus }; + return { $, cockpit, store, converted, hiddenFocus, globe, aircraft }; } const shown = ($) => ({ @@ -135,7 +128,7 @@ test('a tracked aircraft shows the entry and a TR-3B chip that names its action' }); test('entering shows the HUD and focuses the exit once it renders; exiting returns focus to the entry', async (t) => { - const { $, cockpit } = fixture(t); + const { $, cockpit, globe, aircraft } = fixture(t); cockpit.syncEntry(); await nextTick(); const entry = $('#cockpit-entry'); @@ -155,9 +148,17 @@ test('entering shows the HUD and focuses the exit once it renders; exiting retur const hud = $('#cockpit-hud'); assert.equal(hud.classList.contains('signals-active'), true); assert.equal(document.activeElement?.id, 'map-view-switch'); + // First person: the ridden aircraft's glyph is not drawn, the engine's + // follow and camera inputs are the Cockpit's. + assert.equal(aircraft.show, false); + assert.equal(globe.following(), null); + assert.equal(globe.engine.fake.cameraInputsEnabled, false); $('#map-view-switch').click(); assert.equal(cockpit.active, false); + assert.equal(aircraft.show, true); + assert.equal(globe.following(), aircraft, 'the follow resumes'); + assert.equal(globe.engine.fake.cameraInputsEnabled, true); await nextTick(); await Promise.resolve(); assert.deepEqual(shown($), { diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index ced3726e7..38f08ee34 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -7,8 +7,9 @@ import { } from '../cockpitMath.js'; export function readAircraftInfo() { - // In cockpit mode the controller takes the entity off `viewer.trackedEntity` - // (see update()), so the cockpit's own handle is the tracked identity there. + // In cockpit mode the controller releases the engine's follow of the + // aircraft (see enter()), so the cockpit's own handle is the tracked + // identity there. const trackedEntity = this.host.followed() || this.trackedEntity; return resolveTrackedAircraftInfo({ civilian: this.services.flightsLayer.getTrackedInfo?.() || null, @@ -32,13 +33,11 @@ export function readSubjectLayerId() { } /** - * The subject entry would take: a Cesium-tracked aircraft. + * The subject entry would take: the aircraft the engine follows. * @returns {'aircraft'|null} */ export function resolveEntryKind() { - return this.readAircraftInfo() && this.viewer.trackedEntity?.position - ? 'aircraft' - : null; + return this.readAircraftInfo() && this.host.current() ? 'aircraft' : null; } export function dispatchCockpitModeChanged(active, info = null) { @@ -265,7 +264,7 @@ export function exit({ restoreTracking = true } = {}) { this.trackedEntityWasShown = true; this.dispatchCockpitModeChanged(false); if (restoreTracking && entity && this.host.contains(entity)) { - this.host.restore(entity, this.restoreTrackingFrame); + this.host.restore(entity); } this.syncEntry(); // Back to whichever entry was used: the Contacts button or the readout's. diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index 389815d71..584c47700 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -76,7 +76,7 @@ export class LayerBindings { routeWorldFocusRequest( event, (detail, fly) => this._runExplicitWorldFocus(detail, fly), - (detail) => flyToWorldTarget(this.engine ?? this.viewer, detail), + (detail) => flyToWorldTarget(this.engine, detail), ); this._removeWorldRequestFocusListener = registerWorldFocusRequestListener( window, diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 60d5e6cf6..9ff9df2ce 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -1,19 +1,18 @@ import * as geo from '../geo/index.js'; import { t } from '../i18n/index.js'; import { createStateChannel } from '../app/stateChannel.js'; -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; 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 that no registered engine reports. + * Cesium tracked entity the engine does not report. */ export class LocationNavigation { constructor({ viewer = null, - engine = engineFromViewer(viewer), + engine = null, placeSearch, services, elements, @@ -469,7 +468,7 @@ export class LocationNavigation { } /** Navigate on another globe (an engine switch). */ - attachScene({ viewer = null, engine = engineFromViewer(viewer) } = {}) { + attachScene({ viewer = null, engine = null } = {}) { if (this._disposed) return; this.viewer = viewer; this.engine = engine; diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 47446f23f..703512e0e 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -1,6 +1,5 @@ import { cancelCameraArrival } from '../data/cameraArrival.js'; import { createStateChannel } from '../app/stateChannel.js'; -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; import { beginDeferredNavigation, reassertNavigationHandoff, @@ -15,7 +14,7 @@ import { export class NavigationController { constructor({ viewer = null, - engine = engineFromViewer(viewer), + engine = null, tracking, settleSearch = () => {}, interruptCameraMotion, @@ -289,7 +288,7 @@ export class NavigationController { }); } this.viewer = viewer; - this.engine = engine ?? engineFromViewer(viewer); + this.engine = engine; } /** Subscribe to ownership changes without claiming the camera or exposing mutable state. */ subscribeCameraHandoff(listener) { diff --git a/src/ui/wildlifeSightingCard.js b/src/ui/wildlifeSightingCard.js index 390aedc47..d0b642e6e 100644 --- a/src/ui/wildlifeSightingCard.js +++ b/src/ui/wildlifeSightingCard.js @@ -13,7 +13,7 @@ import { export function createWildlifeSightingCard({ layer, ui, - viewer = null, + engine = null, documentRef = globalThis.document, windowRef = globalThis.window, now = () => Date.now(), @@ -66,7 +66,7 @@ export function createWildlifeSightingCard({ function observeLayout(elements) { const next = new Set( - [host.firstElementChild, viewer?.scene?.canvas, ...elements].filter( + [host.firstElementChild, engine?.host.canvas, ...elements].filter( Boolean, ), ); @@ -97,12 +97,12 @@ export function createWildlifeSightingCard({ function position({ allowPan = false } = {}) { if (destroyed || !selected.value || !host.firstElementChild) return; - const point = project(viewer, selected.value, layer); + const point = project(engine, selected.value, layer); if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { host.style.display = 'none'; return; } - const safe = readWildlifeCardSafeArea({ documentRef, viewer, windowRef }); + const safe = readWildlifeCardSafeArea({ documentRef, engine, windowRef }); observeLayout(safe.elements); host.style.width = `${Math.min(WILDLIFE_SELECTED_CARD_WIDTH, safe.bounds.width)}px`; host.style.maxHeight = `${safe.bounds.height}px`; @@ -137,7 +137,7 @@ export function createWildlifeSightingCard({ const reduced = windowRef?.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true; - pan(viewer, point, result.anchor, reduced); + pan(engine, point, result.anchor, reduced); } host.style.left = `${result.left}px`; host.style.top = `${result.top}px`; @@ -211,7 +211,7 @@ export function createWildlifeSightingCard({ const outside = (event) => { if (!selected.value || host.contains(event.target)) return; // The layer selects on pointer-up; its following click must keep the card open. - const canvas = viewer?.scene?.canvas; + const canvas = engine?.host.canvas; if (canvas && (event.target === canvas || canvas.contains?.(event.target))) return; layer.clearSightingSelection(); @@ -231,19 +231,19 @@ export function createWildlifeSightingCard({ if (!selected.value || removeFrame) return; // Installed only for camera motion; an idle card has zero per-frame work. removeFrame = - viewer?.scene?.preRender?.addEventListener?.(() => position()) || null; + engine?.frames.preRender.addEventListener(() => position()) || null; } - listen(viewer?.camera?.moveStart, () => { + listen(engine?.camera.moveStart, () => { moving = true; startFrameUpdates(); }); - listen(viewer?.camera?.moveEnd, () => { + listen(engine?.camera.moveEnd, () => { moving = false; removeFrame?.(); removeFrame = null; position(); }); - listen(viewer?.camera?.changed, () => { + listen(engine?.camera.changed, () => { if (!moving) position(); }); diff --git a/src/ui/wildlifeSightingCard.test.mjs b/src/ui/wildlifeSightingCard.test.mjs index f685c75e0..14c1f5049 100644 --- a/src/ui/wildlifeSightingCard.test.mjs +++ b/src/ui/wildlifeSightingCard.test.mjs @@ -1,8 +1,9 @@ import '../testSupport/registerDom.mjs'; import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; import { nextTick } from 'vue'; +import * as geo from '../geo/index.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { readFileSync } from 'node:fs'; import { getLocale, setLocale, t } from '../i18n/index.js'; import { LOCALES } from '../i18n/locale.js'; @@ -12,6 +13,7 @@ import { createWildlifeSightingCard } from './wildlifeSightingCard.js'; import { panWildlifeSighting, placeWildlifeSightingCard, + projectWildlifeSighting, readWildlifeCardSafeArea, } from './wildlifeSightingPlacement.js'; import { createUiRuntime } from './vueRuntime.js'; @@ -47,19 +49,6 @@ const rect = (left, top, width, height) => ({ right: left + width, bottom: top + height, }); -const events = () => { - const listeners = new Set(); - return { - addEventListener(fn) { - listeners.add(fn); - return () => listeners.delete(fn); - }, - emit() { - for (const fn of listeners) fn(); - }, - size: () => listeners.size, - }; -}; const settle = async () => { await nextTick(); await Promise.resolve(); @@ -92,13 +81,14 @@ function fixture( }; const ui = createUiRuntime(); const overlays = new Map(); - const moveStart = events(); - const moveEnd = events(); - const changed = events(); - const preRender = events(); - const viewer = { - scene: { canvas, preRender, pick: () => picked }, - camera: { moveStart, moveEnd, changed }, + const engine = createFakeEngine({ width: 1200, height: 900, canvas }); + engine.fake.script('pick', () => picked ?? undefined); + const raise = (name) => ({ emit: () => engine.fake.emit(name) }); + const moveStart = raise('camera.moveStart'); + const moveEnd = raise('camera.moveEnd'); + const preRender = { + ...raise('frames.preRender'), + size: () => engine.frames.preRender.numberOfListeners, }; const state = { enabled: true, @@ -109,11 +99,10 @@ function fixture( rowById: new Map([[initialRow.stableId, initialRow]]), positions: new Map([[initialRow.stableId, { x: 1, y: 2, z: 3 }]]), contextIds: new Set(), - viewer, + engine, }; let picked = null; let hit = null; - let onClick; let clears = 0; let staleListener; const subscriptions = new Set(); @@ -123,18 +112,11 @@ function fixture( state, services: { now: () => NOW, - cesium: { ScreenSpaceEventType: { LEFT_CLICK: 0 } }, overlayHost: { setEntries: (id, entries) => overlays.set(id, entries), hitTest: (_x, _y, { sourceId }) => hit?.sourceId === sourceId ? hit : null, }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction(fn) { - onClick = fn; - }, - destroy() {}, - }), focus: { requestWorldFocus: (value) => flights.push(value) }, }, parts: { @@ -162,7 +144,7 @@ function fixture( const card = createWildlifeSightingCard({ layer, ui, - viewer, + engine, documentRef: document, windowRef, now: () => NOW, @@ -170,7 +152,7 @@ function fixture( projections++; return currentPoint; }, - pan: (_viewer, from, to, reduced) => { + pan: (_engine, from, to, reduced) => { pans.push({ from, to, reduced }); currentPoint = to; return true; @@ -187,7 +169,7 @@ function fixture( selection, layer, card, - viewer, + engine, windowRef, overlays, flights, @@ -204,9 +186,14 @@ function fixture( staleListener: () => staleListener, clears: () => clears, async click(kind = 'dot', target = initialRow) { + // An icon pick names the badge's mover set and instance as well. picked = kind === 'icon' - ? { primitive: { id: `wildlife:${target.stableId}` } } + ? { + id: `wildlife:${target.stableId}`, + owner: 'wildlife-sightings', + instance: 0, + } : { id: `wildlife:${target.stableId}` }; hit = kind === 'ambient photo card' @@ -217,7 +204,7 @@ function fixture( }), } : null; - onClick({ position: { x: 50, y: 50 } }); + engine.fake.input('click', { position: { x: 50, y: 50 } }); await settle(); }, async publish(next) { @@ -459,7 +446,7 @@ test('a selected dot flies to its true position and its card pans clear of chrom ); const safe = readWildlifeCardSafeArea({ documentRef: document, - viewer: f.viewer, + engine: f.engine, windowRef: f.windowRef, }); assert.equal( @@ -481,42 +468,68 @@ test('a selected dot flies to its true position and its card pans clear of chrom test('the camera correction preserves altitude and orientation and honors reduced motion', () => { const flights = []; - const camera = { - positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 250_000), - positionCartographic: { height: 250_000 }, + const engine = createFakeEngine({ + canvas: { getBoundingClientRect: () => rect(0, 0, 1200, 900) }, + }); + engine.fake.setPose({ + position: geo.cartesian3.fromDegrees(0, 0, 250_000), heading: 0.2, pitch: -1.2, roll: 0.1, - pickEllipsoid: (p) => Cesium.Cartesian3.fromDegrees(p.x / 100, p.y / 100), - flyTo: (value) => flights.push(value), - }; - const viewer = { - camera, - scene: { - canvas: { getBoundingClientRect: () => rect(0, 0, 1200, 900) }, - globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - }, - }; + }); + const pose = engine.camera.pose(); + engine.fake.script('pickEllipsoid', (p) => + geo.cartesian3.fromDegrees(p.x / 100, p.y / 100), + ); + engine.camera.flyTo = (value) => flights.push(value); for (const reduced of [false, true]) { assert.equal( - panWildlifeSighting(viewer, { x: 10, y: 10 }, { x: 30, y: 50 }, reduced), + panWildlifeSighting(engine, { x: 10, y: 10 }, { x: 30, y: 50 }, reduced), true, ); const flight = flights.at(-1); assert.ok( Math.abs( - Cesium.Cartographic.fromCartesian(flight.destination).height - 250_000, + geo.cartographic.fromCartesian(flight.destination).height - 250_000, ) < 0.001, ); assert.deepEqual(flight.orientation, { + heading: pose.heading, + pitch: pose.pitch, + roll: pose.roll, + }); + for (const [angle, value] of Object.entries({ heading: 0.2, pitch: -1.2, roll: 0.1, - }); + })) + assert.ok(Math.abs(flight.orientation[angle] - value) < 1e-9, angle); assert.equal(flight.duration, reduced ? 0 : 0.22); } }); +test('a sighting projects through the engine onto the page, and not from behind the globe', () => { + const engine = createFakeEngine({ + canvas: { getBoundingClientRect: () => rect(30, 40, 1024, 768) }, + }); + engine.fake.setPose({ position: geo.cartesian3.fromDegrees(0, 0, 250_000) }); + const centre = projectWildlifeSighting(engine, row, null); + assert.ok(Math.abs(centre.x - (30 + 512)) < 1e-6); + assert.ok(Math.abs(centre.y - (40 + 384)) < 1e-6); + const anchored = { x: 1, y: 2, z: 3 }; + engine.fake.script('worldToWindow', () => ({ x: 10, y: 20 })); + engine.projector.horizonVisible = (position) => position === anchored; + assert.deepEqual( + projectWildlifeSighting(engine, row, { + getSightingPosition: () => anchored, + }), + { x: 40, y: 60 }, + 'the layer anchor wins over the row coordinates', + ); + assert.equal(projectWildlifeSighting(engine, row, null), null); + assert.equal(projectWildlifeSighting(null, row, null), null); +}); + test('the browser click that follows a globe selection does not close the card it opened', async (context) => { const f = fixture(context); await f.click(); @@ -526,8 +539,8 @@ test('the browser click that follows a globe selection does not close the card i f.cardElement(), 'focus lands on the card, not as a ring on its close button', ); - // Cesium selects on pointer-up; the DOM click event lands on the canvas - // afterwards and must not count as an outside click. + // The engine reports the globe click on pointer-up; the DOM click event + // lands on the canvas afterwards and must not count as an outside click. f.canvas.dispatchEvent(new MouseEvent('click', { bubbles: true })); await settle(); assert.ok(f.cardElement(), 'the card stays open'); diff --git a/src/ui/wildlifeSightingPlacement.js b/src/ui/wildlifeSightingPlacement.js index e638b9ccb..6d7ad725a 100644 --- a/src/ui/wildlifeSightingPlacement.js +++ b/src/ui/wildlifeSightingPlacement.js @@ -1,4 +1,4 @@ -import * as Cesium from 'cesium'; +import * as geo from '../geo/index.js'; import { WORLD_OVERLAY_OCCLUDER_SELECTORS } from '../overlays/worldOverlayChrome.js'; // DOM cards also avoid the HUD edges and responsive sheet containers. @@ -32,13 +32,13 @@ const rectAt = (left, top, width, height) => ({ height, }); -/** Read visible chrome rectangles inside the viewer's usable screen area. */ +/** Read visible chrome rectangles inside the globe canvas's usable area. */ export function readWildlifeCardSafeArea({ documentRef, - viewer, + engine, windowRef = globalThis.window, }) { - const canvasRect = viewer?.scene?.canvas?.getBoundingClientRect?.(); + const canvasRect = engine?.host?.canvas?.getBoundingClientRect?.(); const width = windowRef?.innerWidth || documentRef.documentElement.clientWidth; const height = @@ -208,67 +208,51 @@ export function placeWildlifeSightingCard(point, size, safeArea) { } /** Project a front-side sighting into viewport coordinates. */ -export function projectWildlifeSighting(viewer, row, layer) { - if ( - !viewer?.scene || - !Number.isFinite(row?.lat) || - !Number.isFinite(row?.lon) - ) +export function projectWildlifeSighting(engine, row, layer) { + if (!engine || !Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) return null; const position = layer?.getSightingPosition?.(row.stableId) || - Cesium.Cartesian3.fromDegrees(row.lon, row.lat); - const ellipsoid = viewer.scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; - if ( - viewer.camera?.positionWC && - new Cesium.EllipsoidalOccluder( - ellipsoid, - viewer.camera.positionWC, - ).isPointVisible(position) !== true - ) - return null; - const point = Cesium.SceneTransforms.worldToWindowCoordinates( - viewer.scene, - position, - ); + geo.cartesian3.fromDegrees(row.lon, row.lat); + if (engine.projector.horizonVisible(position) !== true) return null; + const point = engine.projector.worldToWindow(position); if (!point) return null; - const rect = viewer.scene.canvas?.getBoundingClientRect?.(); + const rect = engine.host.canvas?.getBoundingClientRect?.(); return { x: point.x + (rect?.left || 0), y: point.y + (rect?.top || 0) }; } /** Translate the camera by the smallest screen correction and preserve its * altitude/orientation. A card must never zoom merely to reveal its anchor. */ -export function panWildlifeSighting(viewer, from, to, reducedMotion = false) { - const camera = viewer?.camera; - if (!camera?.pickEllipsoid || !camera.flyTo) return false; - const rect = viewer.scene.canvas.getBoundingClientRect(); - const local = (p) => new Cesium.Cartesian2(p.x - rect.left, p.y - rect.top); - const ellipsoid = viewer.scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; - const start = camera.pickEllipsoid(local(from), ellipsoid); - const finish = camera.pickEllipsoid(local(to), ellipsoid); - if (!start || !finish || !camera.positionWC) return false; - const delta = Cesium.Cartesian3.subtract( - start, - finish, - new Cesium.Cartesian3(), +export function panWildlifeSighting(engine, from, to, reducedMotion = false) { + const camera = engine?.camera; + if (!camera) return false; + const rect = engine.host.canvas?.getBoundingClientRect?.(); + const local = (p) => ({ + x: p.x - (rect?.left || 0), + y: p.y - (rect?.top || 0), + }); + const start = camera.pickEllipsoid(local(from)); + const finish = camera.pickEllipsoid(local(to)); + if (!start || !finish) return false; + const pose = camera.pose(); + const destination = geo.cartesian3.add( + pose.position, + geo.cartesian3.subtract(start, finish, geo.cartesian3.create()), + geo.cartesian3.create(), ); - const destination = Cesium.Cartesian3.add( - camera.positionWC, - delta, - new Cesium.Cartesian3(), - ); - const cartographic = ellipsoid.cartesianToCartographic(destination); + const cartographic = geo.cartographic.fromCartesian(destination); if (!cartographic) return false; - cartographic.height = camera.positionCartographic.height; + cartographic.height = pose.cartographic.height; camera.flyTo({ - destination: ellipsoid.cartographicToCartesian(cartographic), + destination: geo.cartographic.toCartesian(cartographic), orientation: { - heading: camera.heading, - pitch: camera.pitch, - roll: camera.roll, + heading: pose.heading, + pitch: pose.pitch, + roll: pose.roll, }, duration: reducedMotion ? 0 : 0.22, - easingFunction: Cesium.EasingFunction.QUADRATIC_OUT, + // The contract has no ease-out; linear keeps the correction prompt. + easing: 'linear', }); return true; } diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 86fcd8725..9c7e5055f 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -54,7 +54,6 @@ import { } from '../cameraVerbs.js'; import * as defaultFloorServices from '../data/groundFloor.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; import { engineCapabilities } from '../globe/contract.js'; import { unavailablePlaceSearch } from '../search/placeSearch.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; @@ -319,13 +318,13 @@ const cameraCartographic = (engine) => /** * Create application actions over the supplied scene and services. The - * actions drive the globe `engine` (by default the one registered for the - * viewer). Each action checks its scene services and render/camera capabilities; - * a missing capability returns UNAVAILABLE_ON_ENGINE on either engine. + * actions drive the globe `engine`. Each action checks its scene services and + * render/camera capabilities; a missing capability returns + * UNAVAILABLE_ON_ENGINE on either engine. */ export function createGevActionRunner({ viewer, - engine: givenEngine = null, + engine = null, styleManager, dataManager, sceneDirector = null, @@ -348,7 +347,6 @@ export function createGevActionRunner({ const builders = speechBuilders ? { ...SPEECH_BUILDERS, ...speechBuilders } : SPEECH_BUILDERS; - const engine = givenEngine ?? engineFromViewer(viewer); // A host that replaces this runner when the globe engine changes passes // the analyst's follow-up memory and the layer enable times, so the next // runner keeps them. @@ -3337,15 +3335,13 @@ function runManagedVoiceNavigation( /** Gathers tracked/selected entities across layer families for read-back. */ /** - * @param {object} target The globe engine, or a Cesium viewer with a - * registered engine (the HUD). + * @param {import('../globe/contract.js').GlobeEngine | null} engine */ export async function getBasemapLabelContext( - target, + engine, service = defaultGeospatial, { cachedOnly = false, previous = null } = {}, ) { - const engine = engineFromViewer(target); const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service); const viewTarget = engine ? getViewTargetCartographic(engine) : null; if (!viewTarget) { diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 484a20e0c..3ace6c406 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -34,10 +34,6 @@ import { MAP_STACKS } from '../mapStackController.js'; import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js'; import { presentResult } from './resultDisplay.js'; import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; -import { - engineFromViewer, - registerViewerEngine, -} from '../globe/cesium/engineFromViewer.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; test('every live basemap is reachable by its own id — no enum value without a voice alias', () => { @@ -196,9 +192,9 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) { }; // A Viewer's canvas is its scene's. viewer.canvas = viewer.scene.canvas; - // Location flights run on the globe engine registered for the viewer, as - // the app's scene engine is (src/app/globeEngine.js). - registerViewerEngine(viewer, createCesiumEngine(viewer)); + // Location flights run on the Cesium engine over the viewer, as the app's + // scene engine does (src/app/globeEngine.js). + testEngines.set(viewer, createCesiumEngine(viewer)); const styleManager = { runImmediateNavigation(noun, navigate, releaseOptions = undefined) { return runExplicitNavigation({ @@ -5277,32 +5273,38 @@ const testPlaceSearch = () => resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__, }); function createGevActionRunner(options) { - registerTestEngine(options.viewer); - return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); + return createActionRunner({ + placeSearch: testPlaceSearch(), + ...options, + engine: options.engine ?? testEngineFor(options.viewer), + }); } /** - * The runner drives the globe engine registered for its viewer, as the app's - * scene engine is (src/app/globeEngine.js): a test viewer gets the Cesium - * engine over it or, when its camera only states where it is - * (positionCartographic), a fake engine at that place. + * The runner drives the globe engine of its viewer, as the app's runner + * drives the scene engine (src/app/globeEngine.js): a test viewer gets the + * Cesium engine over it or, when its camera only states where it is + * (positionCartographic), a fake engine at that place. One engine per viewer. */ -function registerTestEngine(viewer) { - if (!viewer || engineFromViewer(viewer)) return; +const testEngines = new WeakMap(); +function testEngineFor(viewer) { + if (!viewer) return null; + let engine = testEngines.get(viewer); + if (engine) return engine; const camera = viewer.camera ?? {}; const place = Object.getOwnPropertyDescriptor( camera, 'positionCartographic', )?.value; - registerViewerEngine( - viewer, + engine = !camera.positionWC && place ? createFakeEngine({ longitude: place.longitude, latitude: place.latitude, altitude: place.height, }) - : createCesiumEngine(viewer), - ); + : createCesiumEngine(viewer); + testEngines.set(viewer, engine); + return engine; } function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { diff --git a/src/voice/gevRealtime.js b/src/voice/gevRealtime.js index d649b25a4..64df161c6 100644 --- a/src/voice/gevRealtime.js +++ b/src/voice/gevRealtime.js @@ -6,7 +6,6 @@ import { cameraPoseKey, createPointerTracker } from './pointerContext.js'; import { createPointerPicker } from './pointerPick.js'; import { createReferentRegistry } from './referents.js'; import { createSceneRunner } from './sceneRunner.js'; -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; export * from './realtimeController.js'; const ACTION_NAMES = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name)); @@ -141,7 +140,7 @@ export function createSceneVoice(options) { attachScene(attached) { if (lifetime?.aborted) return; detachScene(); - const engine = attached.engine ?? engineFromViewer(attached.viewer); + const engine = attached.engine ?? null; scene.engine = engine; shell = attached.styleManager ?? null; pick = createPointerPicker({ diff --git a/src/worldFocus.js b/src/worldFocus.js index 4e21dca77..c53ca8322 100644 --- a/src/worldFocus.js +++ b/src/worldFocus.js @@ -2,7 +2,6 @@ * One-click camera transfer for layer-owned world targets. */ import * as geo from './geo/index.js'; -import { engineFromViewer } from './globe/cesium/engineFromViewer.js'; export const WORLD_FOCUS_REQUEST_EVENT = 'gev:world-request-focus'; export const WORLD_CLICK_FOCUS_DURATION_SEC = 1.9; @@ -78,11 +77,10 @@ export function routeWorldFocusRequest(event, runExplicitFocus, fly) { /** * Fly to a world target after ownership has been released. - * @param {import('./globe/contract.js').GlobeEngine | object | null} engine - * The globe engine, or a viewer registered for one. + * @param {import('./globe/contract.js').GlobeEngine | null} engine */ export function flyToWorldTarget(engine, target = {}) { - const camera = engineFromViewer(engine)?.camera; + const camera = engine?.camera; const framing = WORLD_FOCUS_FRAMING[target.kind]; if (!camera || !framing || !isValidWorldFocusTarget(target)) return false; const { heading: current } = camera.pose();