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();