From 30c86a600173034a3246b017fb4724283a75b149 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 7 Nov 2026 07:13:56 +0400 Subject: [PATCH] Draw Mapped Installations, Global Context and the infrastructure layers through one display each - Mapped Installations: one display holds the single copy of the logic the Cesium and Navara renderers each had (renderable records, painting, the selection fill, floors, named markers, selection). Discs are point movers, outlines ground corridors and the selection fill a ground polygon. Cesium's behaviour wins where they drifted: named areas draw their outlines (Navara dropped them), discs sit under the moving contacts, and outlines and the fill pick their site. - Global Context: the range ring is a halo and a core line on engine.lines and the awareness logic no longer imports Cesium. The animated pulse disc, which no primitive draws yet, sits behind one seam, render/pulseDisc.js, with a Cesium and a Navara backend. - Datacenters and dams: one display on movers, lines and ground sets with Cesium's LOD, motion probe, floor sampling and card cohort; localGeojsonCore.js keeps only the engine-free card and record code (1381 to 329 lines). Coupling: layer renderer lines fall from 1011/4832 to 351/3349 (Cesium/Navara), `viewer` lines from 1745 to 1591, ScreenSpaceEventHandler files from 8 to 6, Navara ground passes from 5 to 4. --- scripts/check-import-directions.mjs | 3 + scripts/engine-coupling-baseline.json | 20 +- scripts/format-scope.json | 24 +- scripts/package-boundaries.json | 51 +- src/app/layers/militaryInstallations.js | 11 +- src/app/layers/renderers.js | 7 + src/app/layers/renderers.test.mjs | 7 + src/app/navaraMode.js | 44 +- src/data/infrastructure.js | 9 +- src/data/infrastructure.test.mjs | 104 +- src/data/inputOwnership.test.mjs | 12 +- src/data/localGeojson.analyst.test.mjs | 142 +- src/data/localGeojson.test.mjs | 1438 ++--------------- src/data/localGeojsonCore.js | 1070 +----------- src/data/localGeojsonLifecycle.test.mjs | 175 -- src/data/localGeojsonLod.js | 12 +- src/data/militaryAwareness.test.mjs | 30 +- src/data/militaryInstallations.test.mjs | 243 +-- src/data/sharedOsmDisplays.test.mjs | 16 +- src/data/trackedReadout.test.mjs | 6 +- src/layers/awareness/lifecycle.js | 8 +- src/layers/awareness/rangeRing.test.mjs | 440 ++--- src/layers/awareness/render/cesium.js | 30 - src/layers/awareness/render/cesiumPulses.js | 195 +++ src/layers/awareness/render/display.js | 137 ++ src/layers/awareness/render/display.test.mjs | 135 ++ src/layers/awareness/render/navara.js | 132 -- src/layers/awareness/render/pulseDisc.js | 34 + src/layers/awareness/rendering.js | 450 +----- src/layers/infrastructure/index.js | 46 +- src/layers/infrastructure/render/display.js | 774 +++++++++ .../infrastructure/render/display.test.mjs | 1345 +++++++++++++++ src/layers/infrastructure/render/navara.js | 358 ---- src/layers/infrastructure/session.test.mjs | 282 ++++ src/layers/installations/lifecycle.test.mjs | 79 +- .../installations/namedMarkers.test.mjs | 71 +- src/layers/installations/navigation.test.mjs | 87 +- src/layers/installations/render/cesium.js | 14 - .../installations/render/cesium/lifecycle.js | 121 -- .../render/cesium/namedMarkers.js | 225 --- .../installations/render/cesium/rendering.js | 376 ----- .../render/cesium/rendering.test.mjs | 72 - .../installations/render/cesium/selection.js | 84 - src/layers/installations/render/display.js | 710 ++++++++ .../installations/render/display.test.mjs | 419 +++++ .../installations/render/displayHarness.mjs | 158 ++ .../installations/render/namedMarkers.js | 267 +++ src/layers/installations/render/navara.js | 638 -------- .../installations/render/navara.test.mjs | 240 --- .../installations/render/navaraGeometry.js | 201 --- .../render/navaraGeometry.test.mjs | 136 -- .../render/navaraOutlineBatch.js | 63 - .../render/navaraOutlineBatch.test.mjs | 97 -- .../render/navaraStencilBatch.js | 85 - .../render/navaraStencilBatch.test.mjs | 212 --- src/layers/installations/render/style.js | 36 + src/layers/installations/selection.test.mjs | 75 +- .../installations/selectionFill.test.mjs | 190 ++- src/layers/installations/state.js | 4 - src/overlays/worldOverlay.test.mjs | 50 +- src/sources/militaryTileGeometry.test.mjs | 114 +- 61 files changed, 5510 insertions(+), 7104 deletions(-) delete mode 100644 src/data/localGeojsonLifecycle.test.mjs delete mode 100644 src/layers/awareness/render/cesium.js create mode 100644 src/layers/awareness/render/cesiumPulses.js create mode 100644 src/layers/awareness/render/display.js create mode 100644 src/layers/awareness/render/display.test.mjs delete mode 100644 src/layers/awareness/render/navara.js create mode 100644 src/layers/awareness/render/pulseDisc.js create mode 100644 src/layers/infrastructure/render/display.js create mode 100644 src/layers/infrastructure/render/display.test.mjs delete mode 100644 src/layers/infrastructure/render/navara.js delete mode 100644 src/layers/installations/render/cesium.js delete mode 100644 src/layers/installations/render/cesium/lifecycle.js delete mode 100644 src/layers/installations/render/cesium/namedMarkers.js delete mode 100644 src/layers/installations/render/cesium/rendering.js delete mode 100644 src/layers/installations/render/cesium/rendering.test.mjs delete mode 100644 src/layers/installations/render/cesium/selection.js create mode 100644 src/layers/installations/render/display.js create mode 100644 src/layers/installations/render/display.test.mjs create mode 100644 src/layers/installations/render/displayHarness.mjs create mode 100644 src/layers/installations/render/namedMarkers.js delete mode 100644 src/layers/installations/render/navara.js delete mode 100644 src/layers/installations/render/navara.test.mjs delete mode 100644 src/layers/installations/render/navaraGeometry.js delete mode 100644 src/layers/installations/render/navaraGeometry.test.mjs delete mode 100644 src/layers/installations/render/navaraOutlineBatch.js delete mode 100644 src/layers/installations/render/navaraOutlineBatch.test.mjs delete mode 100644 src/layers/installations/render/navaraStencilBatch.js delete mode 100644 src/layers/installations/render/navaraStencilBatch.test.mjs create mode 100644 src/layers/installations/render/style.js diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index e8ff26de3..b247a4b84 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -34,6 +34,9 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/; // cesium only in render/cesium*.js, three and Navara only in // render/navara*.js. The list grows with each layer moved onto renderers. const RENDERED_LAYER_DIRS = [ + 'src/layers/infrastructure/', + 'src/layers/installations/', + 'src/layers/awareness/', 'src/layers/droneAirspace/', 'src/layers/cyclones/', 'src/layers/ghostMaps/', diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index 6c4427255..0a9aa18ab 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -1,16 +1,16 @@ { "metrics": { - "cesiumLayerRendererLines": 1011, - "navaraLayerRendererLines": 4832, - "cesiumImportFiles": 53, - "viewerLines": 1745, - "navaraKindChecks": 9, + "cesiumLayerRendererLines": 351, + "navaraLayerRendererLines": 3349, + "cesiumImportFiles": 51, + "viewerLines": 1591, + "navaraKindChecks": 8, "engineSniffCalls": 14, - "engineExtensionReads": 19, - "groundEffectPasses": 5, + "engineExtensionReads": 18, + "groundEffectPasses": 4, "cesiumSdkLoads": 6, - "screenSpaceHandlerFiles": 8, - "engineNameLines": 108 + "screenSpaceHandlerFiles": 6, + "engineNameLines": 101 }, - "measuredAt": "after the ground layers (earthquakes, air quality, GNSS, perimeters, drone airspace, FIRMS, GhostMaps, cyclones, cables), 2026-11-07" + "measuredAt": "after the installations, Global Context and infrastructure displays, 2026-11-07" } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 562b7909c..6aa4e6820 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1231,9 +1231,6 @@ "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", - "src/layers/installations/render/cesium/selection.js", "src/layers/bikeshare/presentation.js", "src/layers/bikeshare/stationPoints.js", "src/layers/bikeshare/presentation.test.mjs", @@ -1411,7 +1408,6 @@ "scripts/qa-w2-imagery.mjs", "src/globe/navara/postSamplingPass.test.mjs", "src/globe/navara/emptySceneRender.test.mjs", - "src/layers/installations/render/navaraStencilBatch.test.mjs", "src/data/firmsIntensity.js", "src/data/firmsIntensity.test.mjs", "src/ui/layerPanel/RowSwitch.vue", @@ -1626,5 +1622,23 @@ "src/layers/perimeters/render/display.test.mjs", "src/layers/submarineCables/render/display.js", "src/layers/submarineCables/render/display.test.mjs", - "src/testSupport/groundDisplayEngines.mjs" + "src/testSupport/groundDisplayEngines.mjs", + "src/layers/awareness/render/cesiumPulses.js", + "src/layers/awareness/render/display.js", + "src/layers/awareness/render/display.test.mjs", + "src/layers/awareness/render/pulseDisc.js", + "src/layers/infrastructure/render/display.js", + "src/layers/infrastructure/render/display.test.mjs", + "src/layers/installations/render/display.js", + "src/layers/installations/render/display.test.mjs", + "src/layers/installations/render/displayHarness.mjs", + "src/layers/installations/render/namedMarkers.js", + "src/layers/installations/render/style.js", + "src/layers/installations/selection.test.mjs", + "src/layers/installations/selectionFill.test.mjs", + "src/layers/installations/lifecycle.test.mjs", + "src/layers/installations/navigation.test.mjs", + "src/layers/infrastructure/index.js", + "src/layers/infrastructure/session.test.mjs", + "src/data/localGeojson.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c3e0fcb58..7bd8c246e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -11,9 +11,14 @@ "src/data/localGeojsonLod.js", "src/data/inputOwnership.js", "src/data/infrastructureOverlayEntry.js", - "src/sources/infrastructureData.js" + "src/sources/infrastructureData.js", + "src/geo/", + "src/layers/infrastructure/index.js", + "src/layers/infrastructure/render/display.js", + "src/layers/shared/color.js", + "src/sources/requestDeadline.js" ], - "external": ["cesium"] + "external": [] }, "application": { "exports": ["./application"], @@ -1789,12 +1794,7 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/layers/installations/classLabel.js", - "src/layers/installations/render/cesium/lifecycle.js", - "src/layers/installations/render/cesium.js", - "src/layers/installations/render/cesium/rendering.js", - "src/layers/installations/render/cesium/selection.js", "src/layers/shared/color.js", - "src/layers/installations/render/cesium/namedMarkers.js", "src/geo/", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js" @@ -1826,7 +1826,6 @@ "src/layers/awareness/policy.js", "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", - "src/layers/awareness/render/cesium.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", "src/layers/awareness/subject.js", @@ -2623,7 +2622,6 @@ "src/layers/awareness/policy.js", "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", - "src/layers/awareness/render/cesium.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", "src/layers/awareness/subject.js", @@ -3414,15 +3412,10 @@ "src/layers/alpr/presentation.js", "src/layers/bikeshare/selectionModel.js", "src/layers/bikeshare/presentation.js", - "src/layers/installations/render/cesium/lifecycle.js", - "src/layers/installations/render/cesium.js", - "src/layers/installations/render/cesium/rendering.js", - "src/layers/installations/render/cesium/selection.js", "src/layers/vessels/icons.js", "src/maps/sceneApply.js", "src/layers/localAdsb/qaDrawn.js", "src/ui/imageryBoxNavara.js", - "src/layers/installations/render/cesium/namedMarkers.js", "src/geo/", "src/time/clock.js", "src/globe/navara/overlay.js", @@ -3467,10 +3460,7 @@ "src/celestialRing/render/cesium.js", "src/celestialRing/view.js", "src/layers/shared/render/navaraPolygons.js", - "src/layers/installations/render/navara.js", - "src/layers/awareness/render/navara.js", "src/layers/awareness/render/navaraPulses.js", - "src/layers/infrastructure/render/navara.js", "src/layers/shared/render/navaraStems.js", "src/layers/shared/render/navaraSceneDepth.js", "src/layers/directions/render/navara.js", @@ -3488,9 +3478,6 @@ "src/layers/shared/render/groundRing.js", "src/layers/bikeshare/stationPoints.js", "src/layers/shared/viewRectangle.js", - "src/layers/installations/render/navaraGeometry.js", - "src/layers/installations/render/navaraOutlineBatch.js", - "src/layers/installations/render/navaraStencilBatch.js", "src/layers/firms/rectangle.js", "src/layers/shared/render/navaraMoverBank.js", "src/layers/shared/render/moverBank.js", @@ -3584,7 +3571,14 @@ "src/layers/gnss/render/display.js", "src/layers/firms/render/cesiumHeat.js", "src/layers/firms/render/heatField.js", - "src/layers/firms/render/display.js" + "src/layers/firms/render/display.js", + "src/layers/installations/render/style.js", + "src/layers/awareness/render/cesiumPulses.js", + "src/layers/awareness/render/pulseDisc.js", + "src/layers/awareness/render/display.js", + "src/layers/installations/render/namedMarkers.js", + "src/layers/installations/render/display.js", + "src/layers/infrastructure/render/display.js" ], "external": [ "hls.js", @@ -3763,7 +3757,6 @@ "src/layers/awareness/policy.js", "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", - "src/layers/awareness/render/cesium.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", "src/layers/awareness/subject.js", @@ -4230,13 +4223,8 @@ "src/layers/bikeshare/selectionModel.js", "src/layers/bikeshare/presentation.js", "src/layers/bikeshare/stationPoints.js", - "src/layers/installations/render/cesium/lifecycle.js", - "src/layers/installations/render/cesium.js", - "src/layers/installations/render/cesium/rendering.js", - "src/layers/installations/render/cesium/selection.js", "src/layers/vessels/icons.js", "src/layers/localAdsb/qaDrawn.js", - "src/layers/installations/render/cesium/namedMarkers.js", "src/geo/", "src/time/clock.js", "src/globe/follow.js", @@ -4320,7 +4308,14 @@ "src/layers/ghostMaps/render/display.js", "src/layers/firms/render/cesiumHeat.js", "src/layers/firms/render/heatField.js", - "src/layers/firms/render/display.js" + "src/layers/firms/render/display.js", + "src/layers/infrastructure/render/display.js", + "src/layers/installations/render/style.js", + "src/layers/awareness/render/cesiumPulses.js", + "src/layers/awareness/render/pulseDisc.js", + "src/layers/awareness/render/display.js", + "src/layers/installations/render/namedMarkers.js", + "src/layers/installations/render/display.js" ], "external": [ "hls.js", diff --git a/src/app/layers/militaryInstallations.js b/src/app/layers/militaryInstallations.js index 6c4376ab4..2ed0e698e 100644 --- a/src/app/layers/militaryInstallations.js +++ b/src/app/layers/militaryInstallations.js @@ -1,22 +1,23 @@ import { overlayHost } from './overlayHost.js'; import * as credits from '../../data/dataCredits.js'; import { createInstallationsLayer } from '../../layers/installations/index.js'; -import { createInstallationsCesiumRenderer } from '../../layers/installations/render/cesium.js'; import { rendererFor } from './renderers.js'; import * as render from '../../renderGovernor.js'; import * as context from '../../data/contextStore.js'; import * as picking from '../../data/pickRegistry.js'; -/** Construct one layer using the application scene owners and a supplied source. */ +/** + * Construct one layer using the application scene owners and a supplied + * source; it draws with the engine's renderer from the registry + * (render/display.js on every engine). + */ export function createApplicationInstallations({ surface, source }) { const { groundFloor: ground, anchors } = surface; return createInstallationsLayer({ source, overlayHost, rendererFactory: ({ engine, ...context }) => - engine?.kind === 'navara' - ? rendererFor('military-installations', engine, context) - : createInstallationsCesiumRenderer(context), + rendererFor('military-installations', engine, context), services: { credits, render, diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 744c9ba2b..d614d7aaa 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -46,6 +46,9 @@ import { createFirmsDisplay } from '../../layers/firms/render/display.js'; import { createGhostMapsDisplay } from '../../layers/ghostMaps/render/display.js'; import { createCyclonesDisplay } from '../../layers/cyclones/render/display.js'; import { createCablesDisplay } from '../../layers/submarineCables/render/display.js'; +import { createInfrastructureDisplay } from '../../layers/infrastructure/render/display.js'; +import { createInstallationsDisplay } from '../../layers/installations/render/display.js'; +import { createAwarenessDisplay } from '../../layers/awareness/render/display.js'; const ENGINE_KINDS = new Set(['cesium', 'navara']); @@ -119,6 +122,8 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({ 's2-ghostmaps': createGhostMapsDisplay, 'weather-cyclones': createCyclonesDisplay, 'telegeography-submarine-cables': createCablesDisplay, + 'local-datacenters': createInfrastructureDisplay, + 'local-dams': createInfrastructureDisplay, 'rocket-launches': createLaunchesDisplay, satellites: createSatellitesDisplay, flights: createFlightsDisplay, @@ -126,6 +131,8 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({ 'local-adsb': createLocalAdsbDisplay, 'ais-live-vessels': createVesselsDisplay, cctv: createCctvDisplay, + 'military-installations': createInstallationsDisplay, + 'military-awareness': createAwarenessDisplay, }); // The other application layers still draw themselves on Cesium. diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index e7e5c9f22..33aea6d53 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -74,6 +74,8 @@ test('the layers with extracted renderers', () => { 's2-ghostmaps', 'weather-cyclones', 'telegeography-submarine-cables', + 'local-datacenters', + 'local-dams', 'rocket-launches', 'satellites', 'flights', @@ -81,6 +83,8 @@ test('the layers with extracted renderers', () => { 'local-adsb', 'ais-live-vessels', 'cctv', + 'military-installations', + 'military-awareness', ]); }); @@ -104,6 +108,9 @@ test('the layers drawn through one display get the same factory on every engine' 'drone-airspace': ['droneAirspace', 'createDroneAirspaceDisplay'], 's2-ghostmaps': ['ghostMaps', 'createGhostMapsDisplay'], 'weather-cyclones': ['cyclones', 'createCyclonesDisplay'], + 'local-datacenters': ['infrastructure', 'createInfrastructureDisplay'], + 'local-dams': ['infrastructure', 'createInfrastructureDisplay'], + 'military-installations': ['installations', 'createInstallationsDisplay'], 'telegeography-submarine-cables': [ 'submarineCables', 'createCablesDisplay', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 1b8f30e75..5c3ca508c 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -182,40 +182,30 @@ export async function registerNavaraLayerRenderers() { 'weather-cyclones': async () => (await import('../layers/cyclones/render/display.js')) .createCyclonesDisplay, - ...Object.fromEntries( - ['local-datacenters', 'local-dams'].map((id) => [ - id, - async () => { - const { createInfrastructureNavaraRenderer } = - await import('../layers/infrastructure/render/navara.js'); - return (engine, { config, services }) => - createInfrastructureNavaraRenderer( - engine, - engine.extensions.navara, - config, - services, - ); - }, - ]), - ), + 'local-datacenters': async () => + (await import('../layers/infrastructure/render/display.js')) + .createInfrastructureDisplay, + 'local-dams': async () => + (await import('../layers/infrastructure/render/display.js')) + .createInfrastructureDisplay, 's2-ghostmaps': async () => (await import('../layers/ghostMaps/render/display.js')) .createGhostMapsDisplay, + // Global Context's display, with the Navara backend of its range pulses. 'military-awareness': async () => { - const { createAwarenessNavaraRenderer } = - await import('../layers/awareness/render/navara.js'); + const [{ createAwarenessDisplay }, { createAwarenessPulseDisc }] = + await Promise.all([ + import('../layers/awareness/render/display.js'), + import('../layers/awareness/render/navaraPulses.js'), + ]); return (engine) => - createAwarenessNavaraRenderer(engine, engine.extensions.navara); - }, - 'military-installations': async () => { - const { createInstallationsNavaraRenderer } = - await import('../layers/installations/render/navara.js'); - return (engine, options) => - createInstallationsNavaraRenderer(engine, { - ...options, - ...engine.extensions.navara, + createAwarenessDisplay(engine, { + navaraPulses: createAwarenessPulseDisc, }); }, + 'military-installations': async () => + (await import('../layers/installations/render/display.js')) + .createInstallationsDisplay, // The FIRMS display, with the Navara backend of its heatmap. 'local-firms': async () => { const [{ createFirmsDisplay }, { createFirmsHeatDrape }] = diff --git a/src/data/infrastructure.js b/src/data/infrastructure.js index 0adc9b5c8..04d6abb49 100644 --- a/src/data/infrastructure.js +++ b/src/data/infrastructure.js @@ -1,14 +1,17 @@ -import { createLocalGeoJsonLayer } from './localGeojsonCore.js'; +import { createInfrastructureSessionLayer } from '../layers/infrastructure/index.js'; import { INFRASTRUCTURE_DATA_URLS } from '../sources/infrastructureData.js'; /** * Create fresh datacenter and dam layers without starting or loading them. - * @param {object} services Caller-owned context, overlay and render operations. + * @param {object} services Caller-owned context, overlay and credit operations. + * @param {(config: object, services: object) => object} [createLayer] One + * layer from its descriptor; by default a session layer that draws through + * the globe contract (src/layers/infrastructure). * @returns {object[]} Datacenters then dams, with stable standalone identities. */ export function createInfrastructureLayers( services, - createLayer = createLocalGeoJsonLayer, + createLayer = createInfrastructureSessionLayer, ) { const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters']; const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams']; diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index c7f1b5a23..a8ce70fd8 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -2,9 +2,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; -import * as Cesium from 'cesium'; import { createInfrastructureLayers } from 'gods-eye-view/infrastructure'; -import { createLocalGeoJsonLayer } from 'gods-eye-view/infrastructure/geojson'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; function services() { const records = new Map(); @@ -13,7 +12,7 @@ function services() { return { records, credited, - showOsmCredit: (viewer) => credited.push(viewer), + showOsmCredit: (engine) => credited.push(engine), overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, registerEntityContext(entity, metadata) { records.set(metadata.id, { entity, ...metadata }); @@ -95,7 +94,7 @@ test('dataset URLs still name the complete bundled sources', () => { } }); -test('two viewers use their supplied contexts and dispose independently', async (t) => { +test('two engines use their supplied contexts and dispose independently', async (t) => { t.mock.method(globalThis, 'fetch', async () => ({ ok: true, text: async () => @@ -118,57 +117,42 @@ test('two viewers use their supplied contexts and dispose independently', async })); const hosts = [services(), services()]; const instances = hosts.map((host) => { - const sources = new Cesium.DataSourceCollection(); - let click; - const layer = createLocalGeoJsonLayer( - { - id: 'local-dams', - name: 'Dams', - osmDerived: true, - color: '#0088ff', - url: '/dams.geojsonl', - screenSpaceEventHandlerFactory: () => ({ - setInputAction(fn) { - click = fn; - }, - destroy() {}, - }), - }, - host, - ); - const viewer = { - dataSources: sources, - scene: { - canvas: {}, - preRender: new Cesium.Event(), - requestRender() {}, - screenSpaceCameraController: { enableInputs: true }, - pick: () => ({ id: sources.get(0).entities.values[0] }), - }, - camera: { moveEnd: new Cesium.Event(), flyTo() {} }, - }; - return { layer, viewer, click: () => click({ position: {} }) }; + const engine = createFakeEngine(); + const [, layer] = createInfrastructureLayers(host); + layer.init(null, { engine }); + return { layer, engine }; }); t.after(() => - instances.forEach(({ layer, viewer }) => layer.destroy(viewer)), + instances.forEach(({ layer, engine }) => { + layer.destroy(); + engine.destroy(); + }), ); - await Promise.all(instances.map(({ layer, viewer }) => layer.enable(viewer))); + await Promise.all(instances.map(({ layer }) => layer.enable())); assert.equal(hosts[0].records.size, 1); assert.deepEqual( hosts.map((host) => host.credited), - instances.map(({ viewer }) => [viewer]), + instances.map(({ engine }) => [engine]), ); assert.equal(hosts[1].records.size, 1); - instances[0].click(); + // A click on the first engine's footprint selects only its own record. + const { engine } = instances[0]; + engine.camera.flyTo = () => {}; + engine.fake.script('pick', { + id: 'local-dams:same-id', + owner: 'local-dams-footprints', + instance: 0, + }); + engine.fake.input('click', { position: { x: 1, y: 1 } }); assert.ok(hosts[0].selection()); assert.equal(hosts[1].selection(), undefined); - instances[0].layer.destroy(instances[0].viewer); + instances[0].layer.destroy(); assert.equal(hosts[0].records.size, 0); assert.equal(hosts[1].records.size, 1); - assert.equal(instances[1].viewer.dataSources.length, 1); + assert.equal(instances[1].engine.ground.list().length, 2); }); -test('consumer build includes only infrastructure code and resolves assets under a non-root base', async () => { +test('consumer build includes only infrastructure code and engine-free geometry, and resolves assets under a non-root base', async () => { const { build } = await import('vite'); const { fileURLToPath } = await import('node:url'); const result = await build({ @@ -189,18 +173,38 @@ test('consumer build includes only infrastructure code and resolves assets under }); const output = result.output; const entry = output.find((item) => item.type === 'chunk' && item.isEntry); - const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js')); - assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [ - 'infrastructure.js', - 'infrastructureData.js', - 'infrastructureOverlayEntry.js', - 'localGeojsonCore.js', - 'localGeojsonLod.js', + const sources = Object.keys(entry.modules) + .filter((id) => id.endsWith('.js')) + .map((id) => id.slice(id.lastIndexOf('/src/') + 1)); + assert.deepEqual(sources.sort(), [ + 'src/data/infrastructure.js', + 'src/data/infrastructureOverlayEntry.js', + 'src/data/localGeojsonCore.js', + 'src/data/localGeojsonLod.js', + ...[ + 'boundingSphere', + 'cartesian2', + 'cartesian3', + 'cartographic', + 'ellipsoid', + 'ellipsoidGeodesic', + 'julianDate', + 'math', + 'matrix4', + 'quaternion', + 'scaleToGeodeticSurface', + 'simon1994', + ].map((name) => `src/geo/${name}.js`), + 'src/layers/infrastructure/index.js', + 'src/layers/infrastructure/render/display.js', + 'src/layers/shared/color.js', + 'src/sources/infrastructureData.js', + 'src/sources/requestDeadline.js', ]); assert.deepEqual( entry.imports, - ['cesium'], - 'the viewer supplies the same Cesium dependency', + [], + 'the layers draw through the engine they are given', ); for (const name of ['datacenters', 'dams']) { const asset = output.find( diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index d4449eb19..acd045cf8 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -115,16 +115,20 @@ test('every scene click handler consults ownership before it picks', () => { // appear here too. const guarded = [ ['src/data/trackingClickGesture.js', 'onClick(click, gesture);'], - ['src/data/localGeojsonCore.js', 'viewer.scene.pick(click.position)'], + [ + 'src/layers/infrastructure/render/display.js', + 'const picked = event.picked;', + ], ['src/data/cctvGizmo.js', 'pickGizmoPart(event.position)'], // ALPR and bikeshare take the engine's click with its pick. ['src/layers/alpr/render/display.js', 'overlay.pick(position) ||'], ['src/layers/bikeshare/render/display.js', 'const picked = event.picked;'], // FIRMS takes the engine dispatcher's click with its pick. ['src/layers/firms/selection.js', 'const picked = click.picked;'], + // Mapped Installations take the engine's click with its pick. [ - 'src/layers/installations/render/cesium/selection.js', - 'viewer.scene.pick(click.position)', + 'src/layers/installations/render/display.js', + "const id = typeof picked?.id === 'string' ? picked.id : null;", ], ['src/layers/launches/render/display.js', '[ctx.picked, ...ctx.drilled]'], [ @@ -170,7 +174,7 @@ test('ambient selection handlers never claim the pointer themselves', () => { // moment a user clicked anything. Only tools claim. for (const file of [ 'src/data/trackingClickGesture.js', - 'src/data/localGeojsonCore.js', + 'src/layers/infrastructure/render/display.js', 'src/data/cctvGizmo.js', 'src/layers/vessels/selection.js', 'src/layers/satellites/interaction.js', diff --git a/src/data/localGeojson.analyst.test.mjs b/src/data/localGeojson.analyst.test.mjs index 33d35a039..1cb6fae2c 100644 --- a/src/data/localGeojson.analyst.test.mjs +++ b/src/data/localGeojson.analyst.test.mjs @@ -1,19 +1,8 @@ -// Focused tests for the local-infrastructure analyst-record mapper and snapshot seam. +// Focused tests for the local-infrastructure analyst-record mapper. The +// layer's snapshot is tested in src/layers/infrastructure/session.test.mjs. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; -import { localGeoJsonServices } from '../app/localGeojsonServices.js'; -import { - createLocalGeoJsonLayer as createLayer, - mapAnalystRecord, -} from './localGeojsonCore.js'; - -/** The catalog's services, with the overlay host each test supplies. */ -const createLocalGeoJsonLayer = (options) => - createLayer(options, { - ...localGeoJsonServices, - overlayHost: options.overlayHost, - }); +import { mapAnalystRecord } from './localGeojsonCore.js'; const DC_RAW = { id: '1176042553', @@ -95,7 +84,7 @@ test('infra analyst record: empty record yields nulls, never NaN/undefined', () } }); -test('infra analyst record: output is JSON-safe (no Cesium types leak)', () => { +test('infra analyst record: output is JSON-safe (no engine types leak)', () => { const r = mapAnalystRecord( { ...DAM_RAW, @@ -108,126 +97,3 @@ test('infra analyst record: output is JSON-safe (no Cesium types leak)', () => { assert.equal('entity' in r, false); assert.equal('position' in r, false); }); - -class MockLayerEvent { - constructor() { - this.listeners = new Set(); - } - - addEventListener(listener) { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } -} - -test('infra getAnalystRecords: enabled layer snapshots loaded stems; disable returns []', async () => { - const originalFetch = globalThis.fetch; - const originalWindow = globalThis.window; - globalThis.window = { dispatchEvent() {} }; - globalThis.fetch = async () => ({ - ok: true, - status: 200, - text: async () => - JSON.stringify({ - type: 'Feature', - id: 'runtime-dam', - properties: { - name: 'Runtime Dam', - output: '12 MW', - tags: { associated_river: 'Test River', operator: 'Test Hydro' }, - }, - geometry: { - type: 'Polygon', - coordinates: [ - [ - [-97.7, 30.2], - [-97.69, 30.2], - [-97.69, 30.21], - [-97.7, 30.2], - ], - ], - }, - }), - }); - const viewer = { - selectedEntity: undefined, - dataSources: { - add(dataSource) { - return dataSource; - }, - remove() { - return true; - }, - }, - camera: { - positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000), - frustum: { fov: Math.PI / 3 }, - moveEnd: new MockLayerEvent(), - flyTo() {}, - }, - scene: { - canvas: { clientWidth: 800, clientHeight: 600 }, - preRender: new MockLayerEvent(), - sampleHeightSupported: false, - sampleHeight() { - return undefined; - }, - screenSpaceCameraController: { enableInputs: true }, - pick() { - return null; - }, - requestRender() {}, - }, - }; - const layer = createLocalGeoJsonLayer({ - id: 'local-dams', - url: '/runtime-dam.geojsonl', - name: 'Runtime Dams', - color: '#0088ff', - overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, - projectToWindow: () => ({ x: 400, y: 300 }), - screenSpaceEventHandlerFactory: () => ({ - setInputAction() {}, - destroy() {}, - }), - }); - try { - assert.deepEqual( - layer.getAnalystRecords(), - [], - 'unenabled layer has no analyst records', - ); - await layer.enable(viewer); - const rows = layer.getAnalystRecords(); - assert.equal(rows.length, 1); - assert.equal(rows[0].id, 'Runtime Dam'); - assert.equal(rows[0].name, 'Runtime Dam'); - assert.equal(rows[0].operator, 'Test Hydro'); - assert.equal(rows[0].river, 'Test River'); - assert.equal(rows[0].output, '12 MW'); - assert.ok(Number.isFinite(rows[0].lat) && Number.isFinite(rows[0].lon)); - assert.deepEqual(JSON.parse(JSON.stringify(rows[0])), rows[0]); - assert.equal( - layer.getAnalystRecords(0).length, - 1, - 'non-finite/zero cap still returns at least one', - ); - - layer.disable(viewer); - assert.deepEqual( - layer.getAnalystRecords(), - [], - 'disable keeps stems but analyst snapshot is empty', - ); - } finally { - layer.destroy(viewer); - assert.deepEqual( - layer.getAnalystRecords(), - [], - 'destroy releases analyst records', - ); - globalThis.fetch = originalFetch; - if (originalWindow === undefined) delete globalThis.window; - else globalThis.window = originalWindow; - } -}); diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 6eae458cb..bcd5de919 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -1,211 +1,69 @@ +// The datacenter and dam layers' engine-free parts (localGeojsonCore.js): +// card copy and entries, the card cohort, the overlay publisher and the +// dataset error text. The drawing is tested in +// src/layers/infrastructure/render/display.test.mjs, the session in +// src/layers/infrastructure/session.test.mjs. + import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import * as Cesium from 'cesium'; +import * as geo from '../geo/index.js'; import { - GROUND_SAMPLE_MAX_ARMED_RETRIES, LOCAL_OVERLAY_COHORT_LIMIT, - LOCAL_STEM_TIP_EPSILON_M, - createLocalGeoJsonLayer as createLayer, createLocalInfrastructureOverlayEntry, createLocalInfrastructureOverlayPublisher, localDatasetError, localInfrastructureOverlayCopy, selectLocalInfrastructureOverlayCohort, } from './localGeojsonCore.js'; -import { localGeoJsonServices } from '../app/localGeojsonServices.js'; -import { layerFeedState } from './manager.js'; -import { INFRA_LOD_ACTIVE_MIN, INFRA_LOD_ACTIVE_MAX } from './localGeojsonLod.js'; -import { - installRenderGovernor, - getRenderGovernorDiagnostics, - _resetRenderGovernorForTest, -} from '../renderGovernor.js'; - -/** The catalog's services, with the overlay host each test supplies. */ -const createLocalGeoJsonLayer = (options) => - createLayer(options, { - ...localGeoJsonServices, - overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost, - }); - -class MockLayerEvent { - constructor() { - this.listeners = new Set(); - this.addCount = 0; - this.removeCount = 0; - } - - addEventListener(listener) { - this.addCount++; - this.listeners.add(listener); - let active = true; - return () => { - if (!active) return; - active = false; - this.removeCount++; - this.listeners.delete(listener); - }; - } - - raise(...args) { - for (const listener of [...this.listeners]) listener(...args); - } -} - -/** - * @param {object} [options] - * @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability. - * @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior - * (default: throws like a scene whose tiles are not sampleable yet). - */ -async function createRealLocalLayerHarness({ - sampleHeightSupported = false, - sampleHeight = () => { throw new Error('tiles not sampleable yet'); }, -} = {}) { - const originalFetch = globalThis.fetch; - const originalWindow = globalThis.window; - const preRender = new MockLayerEvent(); - const moveEnd = new MockLayerEvent(); - const dataSources = []; - const hostCalls = []; - globalThis.fetch = async () => ({ - ok: true, - status: 200, - text: async () => JSON.stringify({ - type: 'Feature', - id: 'real-dam', - properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } }, - geometry: { - type: 'Polygon', - coordinates: [[ - [-97.70, 30.20], - [-97.69, 30.20], - [-97.69, 30.21], - [-97.70, 30.20], - ]], - }, - }), - }); - globalThis.window = { dispatchEvent() {} }; - let sampleHeightImpl = sampleHeight; - const sampleCalls = { count: 0 }; - // The camera frustum, reduced to the one answer ground sampling asks of it. - let baseVisibility = Cesium.Intersect.INSIDE; - const overlayHost = { - setVisible: (...args) => hostCalls.push(['visible', ...args]), - setEntries: (...args) => hostCalls.push(['entries', ...args]), - clearSource: (...args) => hostCalls.push(['clear', ...args]), - }; - const viewer = { - selectedEntity: undefined, - dataSources: { - add(dataSource) { dataSources.push(dataSource); return dataSource; }, - remove(dataSource) { - const index = dataSources.indexOf(dataSource); - if (index >= 0) dataSources.splice(index, 1); - return index >= 0; - }, - }, - camera: { - positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000), - directionWC: new Cesium.Cartesian3(), - upWC: new Cesium.Cartesian3(), - frustum: { - fov: Math.PI / 3, - // No planes: everything is in view. One plane no point satisfies: - // nothing is. - computeCullingVolume: () => ({ - planes: - baseVisibility === Cesium.Intersect.OUTSIDE - ? [new Cesium.Cartesian4(0, 0, 0, -1)] - : [], - }), - }, - moveEnd, - flyTo() {}, - }, - scene: { - canvas: { clientWidth: 800, clientHeight: 600 }, - preRender, - sampleHeightSupported, - sampleHeight: (...args) => { - sampleCalls.count += 1; - return sampleHeightImpl(...args); - }, - screenSpaceCameraController: { enableInputs: true }, - pick() { return null; }, - requestRender() {}, - }, - }; - const layer = createLocalGeoJsonLayer({ - id: 'local-dams', - url: '/runtime-dam.geojsonl', - name: 'Runtime Dams', - color: '#0088ff', - overlayHost, - projectToWindow: () => ({ x: 400, y: 300 }), - screenSpaceEventHandlerFactory: () => ({ - setInputAction() {}, - destroy() {}, - }), - }); - try { - await layer.enable(viewer); - } finally { - globalThis.fetch = originalFetch; - } - return { - layer, - viewer, - dataSources, - hostCalls, - preRender, - moveEnd, - sampleCalls, - /** Swap scene.sampleHeight mid-test (e.g. tiles finally arrive). */ - setSampleHeight(next) { sampleHeightImpl = next; }, - /** Put the feature's base inside (true) or outside the camera frustum. */ - setBaseInView(inView) { - baseVisibility = inView ? Cesium.Intersect.INSIDE : Cesium.Intersect.OUTSIDE; - }, - cleanup() { - if (originalWindow === undefined) delete globalThis.window; - else globalThis.window = originalWindow; - }, - }; -} test('local infrastructure card copy uses the owner-approved source fields', () => { - assert.deepEqual(localInfrastructureOverlayCopy({ - tags: { - name: 'DFW-1', - operator: 'Example Cloud', - 'capacity:it_load': '27 MW', + assert.deepEqual( + localInfrastructureOverlayCopy( + { + tags: { + name: 'DFW-1', + operator: 'Example Cloud', + 'capacity:it_load': '27 MW', + }, + }, + 'local-datacenters', + ), + { + title: 'DFW-1', + details: ['Example Cloud · 27 MW'], }, - }, 'local-datacenters'), { - title: 'DFW-1', - details: ['Example Cloud · 27 MW'], - }); + ); - assert.deepEqual(localInfrastructureOverlayCopy({ - name: 'Barrage Bin el Ouidane', - tags: { associated_river: 'El Abid' }, - }, 'local-dams'), { - title: 'Barrage Bin el Ouidane', - details: ['El Abid'], - }); + assert.deepEqual( + localInfrastructureOverlayCopy( + { + name: 'Barrage Bin el Ouidane', + tags: { associated_river: 'El Abid' }, + }, + 'local-dams', + ), + { + title: 'Barrage Bin el Ouidane', + details: ['El Abid'], + }, + ); - assert.deepEqual(localInfrastructureOverlayCopy({ - tags: { name: 'Amazon Web Services', operator: 'Amazon Web Services' }, - }, 'local-datacenters'), { - title: 'Amazon Web Services', - details: [], - }); + assert.deepEqual( + localInfrastructureOverlayCopy( + { + tags: { name: 'Amazon Web Services', operator: 'Amazon Web Services' }, + }, + 'local-datacenters', + ), + { + title: 'Amazon Web Services', + details: [], + }, + ); }); test('local infrastructure entries satisfy the shared presentation contract', () => { - const position = Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 2000); + const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 2000); const entry = createLocalInfrastructureOverlayEntry({ id: 'dc-42', layerId: 'local-datacenters', @@ -217,13 +75,21 @@ test('local infrastructure entries satisfy the shared presentation contract', () assert.equal(entry.id, 'dc-42'); assert.equal(entry.source, 'local-datacenters'); - assert.equal(entry.position, position, 'entry stays attached to the mutable stem-tip Cartesian'); + assert.equal( + entry.position, + position, + 'entry stays attached to the mutable stem-tip Cartesian', + ); assert.equal(entry.variant, 'card'); assert.equal(entry.title, 'AUS-1'); assert.deepEqual(entry.details, ['Example Cloud']); assert.equal(entry.priority, 1180); assert.equal(entry.collisionGroup, 'ambient-card'); - assert.equal(entry.interactive, false, 'point/stem picking remains Cesium-native'); + assert.equal( + entry.interactive, + false, + 'the display picks its tips and stems', + ); assert.equal(entry.maxDistance, 14_000_000); assert.equal(entry.distanceFadeStartRatio, 250_000 / 14_000_000); assert.deepEqual(entry.distanceScale, { @@ -258,13 +124,14 @@ test('shipped local cohorts keep one grid winner plus bounded surplus contenders height: 900, project: (record) => record.screen, }); - assert.deepEqual(selected.map(({ id }) => id), ['high', 'mid']); + assert.deepEqual( + selected.map(({ id }) => id), + ['high', 'mid'], + ); - const field = Array.from({ length: 220 }, (_, index) => makeRecord( - `record-${index}`, - 1000 - index, - index * 140, - )); + const field = Array.from({ length: 220 }, (_, index) => + makeRecord(`record-${index}`, 1000 - index, index * 140), + ); const datacenters = selectLocalInfrastructureOverlayCohort(field, { maxEntries: 700, cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT, @@ -297,8 +164,14 @@ test('shipped local cohorts keep one grid winner plus bounded surplus contenders project: (record) => record.screen, }); assert.equal(hostBound.length, LOCAL_OVERLAY_COHORT_LIMIT); - assert.equal(hostBound.filter(({ id }) => id.startsWith('primary-')).length, 120); - assert.equal(hostBound.filter(({ id }) => id.startsWith('surplus-')).length, 40); + assert.equal( + hostBound.filter(({ id }) => id.startsWith('primary-')).length, + 120, + ); + assert.equal( + hostBound.filter(({ id }) => id.startsWith('surplus-')).length, + 40, + ); }); test('local overlay publisher owns add/remove/visibility lifecycle and becomes inert on destroy', () => { @@ -325,9 +198,17 @@ test('local overlay publisher owns add/remove/visibility lifecycle and becomes i publisher.show(); publisher.publish([{ id: 'zombie' }]); - assert.equal(calls.length, countAtDestroy, 'destroyed publishers reject late source work'); + assert.equal( + calls.length, + countAtDestroy, + 'destroyed publishers reject late source work', + ); assert.deepEqual(calls[0], ['visible', 'local-datacenters', true]); - assert.deepEqual(calls[1].slice(0, 3), ['entries', 'local-datacenters', [{ id: 'dc-1' }]]); + assert.deepEqual(calls[1].slice(0, 3), [ + 'entries', + 'local-datacenters', + [{ id: 'dc-1' }], + ]); assert.deepEqual(calls[1][3], { cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT, collisionCapacity: 96, @@ -340,1156 +221,17 @@ test('local overlay publisher owns add/remove/visibility lifecycle and becomes i assert.deepEqual(calls[7], ['visible', 'local-datacenters', false]); }); -test('slow parked frames do not keep republishing the same local overlay cohort', async (t) => { - const env = await createRealLocalLayerHarness(); - const clock = installFakeClock(t); - t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); - const publications = () => env.hostCalls.filter(([type]) => type === 'entries'); - env.preRender.raise(); - assert.equal(publications().length, 1); - assert.ok(publications()[0][2].length > 0, 'the initial cohort must be populated'); - for (let i = 0; i < 6; i++) { - clock.advance(1_000); // Every slow frame passes the 450 ms source throttle. - env.preRender.raise(); - } - assert.equal(publications().length, 1, - 'unchanged publication invalidates the real host and requests another slow frame'); - - const camera = env.viewer.camera.positionWC; - env.viewer.camera.positionWC = Cesium.Cartesian3.add(camera, - new Cesium.Cartesian3(10_000, 0, 0), new Cesium.Cartesian3()); - env.moveEnd.raise(); - env.preRender.raise(); - assert.equal(publications().length, 2, 'changed stem positions must still reach the host'); - env.layer.disable(env.viewer); - await env.layer.enable(env.viewer); - env.preRender.raise(); - assert.equal(publications().length, 3, 're-enable must republish after clearing the host'); -}); - -test('real layer disable clears its published host entries and balances settle listeners', async () => { - const env = await createRealLocalLayerHarness(); - env.preRender.raise(); - assert.ok(env.hostCalls.some(([type]) => type === 'entries'), 'real preRender path did not publish'); - - env.layer.disable(env.viewer); - assert.ok( - env.hostCalls.some((call) => call[0] === 'clear' && call[1] === 'local-dams'), - 'real disable path must clear the host source', - ); - assert.equal(env.moveEnd.listeners.size, 0); - assert.equal(env.moveEnd.addCount, 1); - assert.equal(env.moveEnd.removeCount, 1); - - await env.layer.enable(env.viewer); - await env.layer.enable(env.viewer); - assert.equal(env.moveEnd.listeners.size, 1, 'repeated enable must retain one settle listener'); - env.layer.disable(env.viewer); - assert.equal(env.moveEnd.addCount, 2); - assert.equal(env.moveEnd.removeCount, 2); - - await env.layer.enable(env.viewer); - env.layer.destroy(env.viewer); - assert.equal(env.moveEnd.listeners.size, 0); - assert.equal(env.moveEnd.addCount, 3); - assert.equal(env.moveEnd.removeCount, 3); - const addCountAtDestroy = env.moveEnd.addCount; - await env.layer.enable(env.viewer); - assert.equal(env.moveEnd.addCount, addCountAtDestroy, 'destroyed layer must stay permanently inert'); - env.cleanup(); -}); - -test('unchanged moveEnds do not redefine stem constants and real tip changes update once', async () => { - const env = await createRealLocalLayerHarness(); - env.preRender.raise(); - const entity = env.dataSources[0].entities.values[0]; - let positionSetCalls = 0; - let polylineSetCalls = 0; - let polylineDefinitionChanges = 0; - const stemArrays = []; - const initialStemArray = entity.polyline.positions.getValue(); - const originalPositionSet = entity.position.setValue.bind(entity.position); - const originalPolylineSet = entity.polyline.positions.setValue.bind(entity.polyline.positions); - const removeDefinitionListener = entity.polyline.definitionChanged.addEventListener( - (_polyline, propertyName) => { - if (propertyName === 'positions') polylineDefinitionChanges++; - }, - ); - entity.position.setValue = (...args) => { - positionSetCalls++; - return originalPositionSet(...args); - }; - entity.polyline.positions.setValue = (...args) => { - polylineSetCalls++; - stemArrays.push(args[0]); - return originalPolylineSet(...args); - }; - - env.moveEnd.raise(); - env.preRender.raise(); - env.moveEnd.raise(); - env.preRender.raise(); - assert.equal(positionSetCalls, 0); - assert.equal(polylineSetCalls, 0); - assert.equal(polylineDefinitionChanges, 0); - - const camera = env.viewer.camera.positionWC; - env.viewer.camera.positionWC = Cesium.Cartesian3.add( - camera, - new Cesium.Cartesian3(LOCAL_STEM_TIP_EPSILON_M / 10, 0, 0), - new Cesium.Cartesian3(), - ); - env.moveEnd.raise(); - env.preRender.raise(); - assert.equal(positionSetCalls, 0, 'sub-epsilon camera noise must not redefine the tip'); - assert.equal(polylineSetCalls, 0); - assert.equal(polylineDefinitionChanges, 0, 'sub-epsilon jitter must not redefine the polyline'); - - env.viewer.camera.positionWC = Cesium.Cartesian3.add( - camera, - new Cesium.Cartesian3(10_000, 0, 0), - new Cesium.Cartesian3(), - ); - env.moveEnd.raise(); - env.preRender.raise(); - assert.equal(positionSetCalls, 1); - assert.equal(polylineSetCalls, 1); - assert.equal(polylineDefinitionChanges, 1, 'one real tip change must emit one polyline notification'); - - env.viewer.camera.positionWC = Cesium.Cartesian3.add( - camera, - new Cesium.Cartesian3(20_000, 0, 0), - new Cesium.Cartesian3(), - ); - env.moveEnd.raise(); - env.preRender.raise(); - assert.equal(positionSetCalls, 2); - assert.equal(polylineSetCalls, 2); - assert.equal(polylineDefinitionChanges, 2, 'each real tip change must emit exactly one notification'); - - env.viewer.camera.positionWC = Cesium.Cartesian3.add( - camera, - new Cesium.Cartesian3(30_000, 0, 0), - new Cesium.Cartesian3(), - ); - env.moveEnd.raise(); - env.preRender.raise(); - assert.equal(positionSetCalls, 3); - assert.equal(polylineSetCalls, 3); - assert.equal(polylineDefinitionChanges, 3, 'third real tip change must emit exactly one notification'); - assert.notEqual(stemArrays[0], initialStemArray, 'first real update must select the alternate buffer'); - assert.equal(stemArrays[1], initialStemArray, 'second real update must return to the initial buffer'); - assert.equal(stemArrays[2], stemArrays[0], 'consecutive real updates must alternate buffer identity'); - assert.equal( - new Set([initialStemArray, ...stemArrays]).size, - 2, - 'steady-state updates must allocate no stem arrays beyond the two preallocated buffers', - ); - removeDefinitionListener(); - env.layer.destroy(env.viewer); - env.cleanup(); -}); - -test('a real enabled local layer has no native label graphics at runtime', async () => { - const env = await createRealLocalLayerHarness(); - const entities = env.dataSources[0].entities.values; - assert.ok(entities.length > 0, 'runtime guard requires a populated real data source'); - assert.ok(entities.every((entity) => entity.label === undefined)); - env.layer.destroy(env.viewer); - env.cleanup(); -}); - -for (const [heightM, minStemM, maxStemM] of [[500, 45, 90], [10000, 1100, 1400]]) { - test(`local infrastructure keeps stems proportional at ${heightM} m`, async (t) => { - const env = await createRealLocalLayerHarness(); - t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); - const entity = env.dataSources[0].entities.values[0]; - const carto = entity.__localBaseCarto; - env.viewer.camera.positionWC = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, heightM); - env.preRender.raise(env.viewer.scene, Cesium.JulianDate.now()); - const positions = entity.polyline.positions.getValue(Cesium.JulianDate.now()); - const stemM = Cesium.Cartesian3.distance(positions[0], positions[1]); - assert.ok(stemM >= minStemM && stemM <= maxStemM, - `a ${heightM} m close-up must not inherit a globe-sized stem: ${stemM.toFixed(1)} m`); - }); -} - -test('local infrastructure creates no native labels or per-frame geometry callbacks', () => { - const source = readFileSync(new URL('./localGeojsonCore.js', import.meta.url), 'utf8'); - assert.doesNotMatch(source, /new Cesium\.LabelGraphics/); - assert.doesNotMatch(source, /new Cesium\.CallbackProperty/); - assert.match(source, /feature\.position = tip/); - assert.match(source, /record\.entity\.position\.setValue\(record\.tip\)/); - assert.match(source, /const stemPositionBuffers = \[\s*\[base, tip\],\s*\[base, tip\],?\s*\]/); - assert.match(source, /record\.entity\.polyline\.positions\.setValue\(stemPositions\)/); - assert.match(source, /viewer\.camera\.moveEnd\.addEventListener/); - assert.match(source, /if \(refreshStemGeometry \|\| terrainFloorChanged\)/); - assert.match(source, /now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS/); -}); - -// ─── Bundled-dataset failure surfacing (roadmap L7) ─────────── -// -// These datasets ship with the build, so a failed load means a broken -// install. Before this contract the catch only logged: a dead layer and an -// empty one both reported {count: 0} and the manager painted a green ON chip. - -/** Build the failing layer alone — the harness above owns the happy path. */ -async function enableLayerWithFetch(fetchImpl, { dataSources, windowStub } = {}) { - const originalFetch = globalThis.fetch; - const originalWindow = globalThis.window; - globalThis.window = windowStub || { dispatchEvent() {} }; - const viewer = { - dataSources: dataSources || { add() {}, remove() { return true; } }, - camera: { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1000), moveEnd: new MockLayerEvent() }, - scene: { canvas: {}, preRender: new MockLayerEvent(), pick() { return null; } }, - }; - const layer = createLocalGeoJsonLayer({ - id: 'local-dams', - url: '/missing.geojsonl', - name: 'Dams', - color: '#0088ff', - overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, - screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }), - }); - const enable = async () => { - globalThis.fetch = fetchImpl; - try { - await layer.enable(viewer); - } finally { - globalThis.fetch = originalFetch; - } - }; - await enable(); - const cleanup = () => { - layer.destroy(viewer); - if (originalWindow === undefined) delete globalThis.window; - else globalThis.window = originalWindow; - }; - return { layer, viewer, cleanup, enable }; -} - test('a bundled-dataset failure reduces to a short, honest reason', () => { assert.equal( - localDatasetError(new SyntaxError('Unexpected token < in JSON at position 0')), + localDatasetError( + new SyntaxError('Unexpected token < in JSON at position 0'), + ), 'dataset is malformed', ); - assert.equal(localDatasetError(new Error('HTTP 404')), 'dataset unavailable (HTTP 404)'); + assert.equal( + localDatasetError(new Error('HTTP 404')), + 'dataset unavailable (HTTP 404)', + ); assert.equal(localDatasetError(new Error('')), 'dataset unavailable'); assert.equal(localDatasetError(undefined), 'dataset unavailable'); }); - -test('a missing dataset reports UNAVAILABLE instead of a silent empty layer', async () => { - const { layer, cleanup } = await enableLayerWithFetch(async () => ({ - ok: false, - status: 404, - text: async () => '', - })); - const stats = layer.getStats(); - assert.equal(stats.count, 0); - assert.equal(stats.lastUpdate, null); - assert.equal(stats.error, 'dataset unavailable (HTTP 404)'); - assert.equal(layerFeedState(stats), 'unavailable'); - cleanup(); -}); - -test('a corrupt dataset line reports malformed rather than parsing into nothing', async () => { - const { layer, cleanup } = await enableLayerWithFetch(async () => ({ - ok: true, - status: 200, - text: async () => '{"type":"Feature"\n', - })); - const stats = layer.getStats(); - assert.equal(stats.error, 'dataset is malformed'); - assert.equal(layerFeedState(stats), 'unavailable'); - cleanup(); -}); - -// Polygon, like the harness above: Cesium builds Point features through a -// canvas pin builder, which needs a DOM these tests do not have. -const ONE_POLYGON_FEATURE = JSON.stringify({ - type: 'Feature', - id: 'dam-1', - properties: { name: 'Test Dam' }, - geometry: { - type: 'Polygon', - coordinates: [[[-97.70, 30.20], [-97.69, 30.20], [-97.69, 30.21], [-97.70, 30.20]]], - }, -}); - -const serveOnePolygon = async () => ({ - ok: true, - status: 200, - text: async () => ONE_POLYGON_FEATURE, -}); - -/** - * Scene collection with Cesium's real timing: DataSourceCollection.add() - * returns a promise and inserts on a LATER microtask, so the source is not in - * the collection when add() returns. A synchronous mock hides exactly the bug - * this models. - */ -function asyncDataSources({ rejectAdd = false } = {}) { - const added = []; - return { - added, - add(dataSource) { - if (rejectAdd) return Promise.reject(new Error('scene rejected the data source')); - return Promise.resolve().then(() => { - added.push(dataSource); - return dataSource; - }); - }, - remove(dataSource) { - const index = added.indexOf(dataSource); - if (index >= 0) added.splice(index, 1); - return index >= 0; - }, - }; -} - -/** A window whose context store throws once — an exception during post-processing. */ -function windowThatFailsOnce() { - let armed = true; - let store; - return { - dispatchEvent() {}, - get __gevContextStore() { - if (armed) { - armed = false; - throw new Error('post-processing failed'); - } - return store; - }, - set __gevContextStore(value) { store = value; }, - }; -} - -test('a post-processing failure after the scene accepts the source rolls it back, and the retry does not double-add', async () => { - // The window between GeoJsonDataSource.load() and the end of entity - // post-processing. Publishing early made every later enable() skip the - // loader; rolling back before the add settled left Cesium to insert the - // "removed" source afterwards, which the retry would then double up on. - const scene = asyncDataSources(); - const { layer, cleanup, enable } = await enableLayerWithFetch(serveOnePolygon, { - dataSources: scene, - windowStub: windowThatFailsOnce(), - }); - - const failed = layer.getStats(); - assert.equal(failed.error, 'dataset unavailable (post-processing failed)'); - assert.equal(failed.count, 0); - assert.equal(failed.lastUpdate, null); - assert.equal(layerFeedState(failed), 'unavailable'); - assert.equal(scene.added.length, 0, - 'rollback must remove the source the scene already accepted'); - - await enable(); - const retried = layer.getStats(); - assert.equal(retried.error, null, 'the retry must clear the error, not skip the loader'); - assert.equal(retried.count, 1); - assert.ok(Number.isFinite(retried.lastUpdate)); - assert.equal(scene.added.length, 1, 'the retry must not leave two sources in the scene'); - cleanup(); -}); - -test('a rejected scene add surfaces as an error instead of healthy stats', async () => { - const scene = asyncDataSources({ rejectAdd: true }); - const { layer, cleanup } = await enableLayerWithFetch(serveOnePolygon, { - dataSources: scene, - }); - const stats = layer.getStats(); - assert.equal(stats.error, 'dataset unavailable (scene rejected the data source)'); - assert.equal(stats.count, 0); - assert.equal(stats.lastUpdate, null); - assert.equal(layerFeedState(stats), 'unavailable'); - assert.equal(scene.added.length, 0); - cleanup(); -}); - -test('a loaded dataset is distinguishable from a dead one', async () => { - const env = await createRealLocalLayerHarness(); - const stats = env.layer.getStats(); - assert.equal(stats.error, null); - assert.ok(stats.count > 0); - assert.ok(Number.isFinite(stats.lastUpdate), 'a successful load must timestamp itself'); - assert.equal(layerFeedState(stats), 'nominal'); - env.layer.destroy(env.viewer); - env.cleanup(); -}); - -// ── Ground-sample retry vs the idle render governor ─────────────────────────── -// -// These layers take NO continuous hold and have updateInterval 0, so nothing -// else ever asks for a frame. The stem grounding retry lives in preRender: if -// the first sample fails (tiles not yet sampleable) and no frame is scheduled, -// a parked camera never produces the retry frame and the stem stays at -// ellipsoid height — buried in, or floating over, the photoreal mesh until the -// user happens to move. main retried continuously; under the governor the -// retry must schedule its own frame. (perf rebase 2026-08-17) - -/** Drop the camera to `altM` above the fixture dam so retries are in range. */ -function setCameraAltitude(env, altM) { - env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-97.695, 30.205, altM); -} - -function governorReasons() { - return getRenderGovernorDiagnostics().recentRequests.map((entry) => entry.reason); -} - -test('ground sampling waits for visible globe tiles before caching a height', async (t) => { - const env = await createRealLocalLayerHarness({ - sampleHeightSupported: true, sampleHeight: () => 117, - }); - const clock = installFakeClock(t); - t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); - env.viewer.scene.globe = { show: true, tilesLoaded: false, getHeight: () => 117 }; - setCameraAltitude(env, 20_000); - env.preRender.raise(); - assert.equal(env.sampleCalls.count, 0, 'streaming terrain must not become a permanent coarse sample'); - assert.ok(Math.abs(baseHeightM(env)) < 0.01); - env.viewer.scene.globe.tilesLoaded = true; - clock.advance(2_100); - env.preRender.raise(); - assert.equal(env.sampleCalls.count, 1, 'the existing retry must sample the settled scene'); - assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); -}); - -test('an unseen base is not sampled and arms no retry until it is in view', async (t) => { - // Out of view the scene has no surface under the base, so the depth sample - // would pass through the Earth (about -6,344 km) and fail on every retry. - const env = await createRealLocalLayerHarness({ - sampleHeightSupported: true, sampleHeight: () => 117, - }); - _resetRenderGovernorForTest(); - const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; - installRenderGovernor({ scene: governorScene }); - governorScene.renders = 0; // discard the governor's own install settling frame - t.mock.timers.enable({ apis: ['setTimeout'] }); - const clock = installFakeClock(t); - t.after(() => { - env.layer.destroy(env.viewer); - env.cleanup(); - _resetRenderGovernorForTest(); - }); - env.viewer.scene.globe = { show: true, tilesLoaded: true, getHeight: () => 117 }; - setCameraAltitude(env, 20_000); - env.setBaseInView(false); - runArmedRetryChain(env, t, clock, 5); - assert.equal(env.sampleCalls.count, 0, 'an off-screen base must not be sampled'); - assert.equal(governorScene.renders, 0, 'an off-screen base must not keep a parked camera rendering'); - assert.ok(Math.abs(baseHeightM(env)) < 0.01); - - // In the frustum but over the horizon: 50 m up, about 67 km away. - env.setBaseInView(true); - env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-97.0, 30.205, 50); - runArmedRetryChain(env, t, clock, 5); - assert.equal(env.sampleCalls.count, 0, 'a base over the horizon must not be sampled'); - assert.equal(governorScene.renders, 0); - - setCameraAltitude(env, 20_000); - env.preRender.raise(); - assert.equal(env.sampleCalls.count, 1, 'the base is sampled on the first walk that sees it'); - assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); -}); - -/** - * Give the harness a real Cesium camera `southM` metres south of the base at - * `cameraM` metres above the ellipsoid, looking at the base's ground at - * `groundM` (or directly away from it). - */ -function aimRealCamera(env, { groundM, cameraM, southM, away = false, fov = Math.PI / 3 }) { - const entity = env.dataSources[0].entities.values[0]; - const base = Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian); - const ground = Cesium.Cartesian3.fromRadians(base.longitude, base.latitude, groundM); - const position = Cesium.Cartesian3.fromRadians( - base.longitude, - base.latitude - southM / 6_371_000, - cameraM, - ); - const direction = Cesium.Cartesian3.normalize( - away - ? Cesium.Cartesian3.subtract(position, ground, new Cesium.Cartesian3()) - : Cesium.Cartesian3.subtract(ground, position, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const right = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross( - direction, - Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position), - new Cesium.Cartesian3(), - ), - new Cesium.Cartesian3(), - ); - const up = Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()); - const frustum = new Cesium.PerspectiveFrustum({ - fov, - aspectRatio: 800 / 600, - near: 1, - far: 1e8, - }); - Object.assign(env.viewer.camera, { - positionWC: position, - directionWC: direction, - upWC: up, - frustum, - }); - return frustum - .computeCullingVolume(position, direction, up) - .computeVisibility(new Cesium.BoundingSphere(entity.__localBaseCartesian, 1)); -} - -const loadedTerrain = (height) => ({ show: true, tilesLoaded: true, getHeight: () => height }); -const hiddenGlobe = { show: false, tilesLoaded: true, getHeight: () => undefined }; - -for (const { label, globe, groundM, cameraM, southM, fov, baseOffScreen } of [ - // The Dead Sea (about -430 m) under a hidden globe, seen steeply down - // through a narrow view: both 0 m and 9,000 m above it are off screen. - { label: 'hidden-globe depression', globe: hiddenGlobe, groundM: -430, cameraM: 20, southM: 180, fov: Math.PI / 12, baseOffScreen: true }, - // 2,500 m terrain from 3 km away at a shallow angle: the ellipsoid-height - // base is far below the bottom of the screen. - { label: 'high ground with loaded terrain', globe: loadedTerrain(2_500), groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true }, - { label: 'high ground under a hidden globe', globe: hiddenGlobe, groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true }, - // A low-geoid coast (about -63 m near Mumbai) from a camera 50 m above the - // ellipsoid: the ellipsoidal horizon would hide the ground itself. - { label: 'ground below the ellipsoid', globe: loadedTerrain(-63), groundM: -63, cameraM: 50, southM: 2_000, baseOffScreen: false }, -]) { - test(`${label} in view is sampled`, async (t) => { - const env = await createRealLocalLayerHarness({ - sampleHeightSupported: true, sampleHeight: () => groundM, - }); - const clock = installFakeClock(t); - t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); - env.viewer.scene.globe = globe; - const baseVisibility = aimRealCamera(env, { groundM, cameraM, southM, fov }); - if (baseOffScreen) - assert.equal(baseVisibility, Cesium.Intersect.OUTSIDE, 'precondition: the ellipsoid-height base is off screen'); - - clock.advance(2_100); - env.preRender.raise(); - assert.equal(env.sampleCalls.count, 1, 'the ground in view is sampled'); - assert.ok(Math.abs(baseHeightM(env) - groundM) < 0.01); - }); -} - -test('under a hidden globe, ground behind the camera is not sampled', async (t) => { - const env = await createRealLocalLayerHarness({ - sampleHeightSupported: true, sampleHeight: () => 2_500, - }); - const clock = installFakeClock(t); - t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); - env.viewer.scene.globe = hiddenGlobe; - aimRealCamera(env, { groundM: 2_500, cameraM: 3_500, southM: 3_000, away: true }); - clock.advance(2_100); - env.preRender.raise(); - assert.equal(env.sampleCalls.count, 0); -}); - -test('settled terrain refinement lifts an already sampled stem without another GPU sample', async (t) => { - const env = await createRealLocalLayerHarness({ - sampleHeightSupported: true, sampleHeight: () => 117, - }); - const clock = installFakeClock(t); - t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); - let terrain = 117; - const globe = { show: true, tilesLoaded: true, getHeight: () => terrain }; - env.viewer.scene.globe = globe; - setCameraAltitude(env, 20_000); - env.preRender.raise(); - assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); - - terrain = 120; - globe.tilesLoaded = false; - clock.advance(500); - env.preRender.raise(); - assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle'); - globe.tilesLoaded = true; - clock.advance(500); - env.preRender.raise(); - assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor'); - - terrain = 110; - clock.advance(500); - env.preRender.raise(); - assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry'); - terrain = 140; - globe.show = false; - clock.advance(500); - env.preRender.raise(); - assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'hidden globe terrain must not constrain geometry'); - assert.equal(env.sampleCalls.count, 1, 'terrain maintenance must not repeat the GPU readback'); -}); - -for (const [terrainHeight, sampledHeight, expectedHeight, globeShown] of [ - [117, -2238, 117, true], - [-400, -410, -400, true], - [117, 180, 180, true], - [117, -20, -20, false], -]) { - test(`ground sampling respects terrain ${terrainHeight} and geometry ${sampledHeight} with globe ${globeShown}`, async (t) => { - const env = await createRealLocalLayerHarness({ - sampleHeightSupported: true, sampleHeight: () => sampledHeight, - }); - installFakeClock(t); - t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); - env.viewer.scene.globe = { show: globeShown, getHeight: () => terrainHeight }; - setCameraAltitude(env, 20_000); - env.preRender.raise(); - assert.ok(Math.abs(baseHeightM(env) - expectedHeight) < 0.01, - `base ${baseHeightM(env)} must respect the visible terrain without flattening roofs or below-sea-level terrain`); - }); -} - -test('a failed ground sample schedules the retry frame the idle governor would never produce', async (t) => { - const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); - _resetRenderGovernorForTest(); - installRenderGovernor({ scene: { requestRender() {} } }); - t.mock.timers.enable({ apis: ['setTimeout'] }); - t.after(() => { - env.layer.destroy(env.viewer); - env.cleanup(); - _resetRenderGovernorForTest(); - }); - - // Close enough that a retry could succeed, on a scene that CAN sample but - // whose tiles are not sampleable yet — a camera parked over a dam while the - // photoreal mesh is still streaming in. - setCameraAltitude(env, 20_000); - env.preRender.raise(); - - assert.ok( - !governorReasons().some((reason) => reason.startsWith('local-ground-retry')), - 'the request must be DEFERRED by the retry window, not fired inline', - ); - t.mock.timers.tick(2_100); // GROUND_SAMPLE_RETRY_MS (2000) + slack - assert.ok( - governorReasons().includes('local-ground-retry:local-dams'), - `expected a scheduled retry render; saw ${JSON.stringify(governorReasons())}`, - ); -}); - -test('a far camera arms no retry render — the governor stays fully idle', async (t) => { - const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); - _resetRenderGovernorForTest(); - installRenderGovernor({ scene: { requestRender() {} } }); - t.mock.timers.enable({ apis: ['setTimeout'] }); - t.after(() => { - env.layer.destroy(env.viewer); - env.cleanup(); - _resetRenderGovernorForTest(); - }); - - // 400 km up: way beyond GROUND_SAMPLE_MAX_DISTANCE_M, so no retry can ever - // succeed and asking for frames would just spin the governor forever. - setCameraAltitude(env, 400_000); - env.preRender.raise(); - t.mock.timers.tick(5_000); - - assert.ok( - !governorReasons().some((reason) => reason.startsWith('local-ground-retry')), - `a far camera must not request frames; saw ${JSON.stringify(governorReasons())}`, - ); -}); - -test('disable cancels a pending ground-retry render', async (t) => { - const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); - _resetRenderGovernorForTest(); - installRenderGovernor({ scene: { requestRender() {} } }); - t.mock.timers.enable({ apis: ['setTimeout'] }); - t.after(() => { - env.layer.destroy(env.viewer); - env.cleanup(); - _resetRenderGovernorForTest(); - }); - - setCameraAltitude(env, 20_000); - env.preRender.raise(); - env.layer.disable(env.viewer); - t.mock.timers.tick(5_000); - - assert.ok( - !governorReasons().some((reason) => reason.startsWith('local-ground-retry')), - `a disabled layer must not wake the scene; saw ${JSON.stringify(governorReasons())}`, - ); -}); - -// ── The retry must be able to STOP (review round 2) ─────────────────────────── -// -// The retry above arms itself off its own requested frame, so anything that -// makes the sample permanently impossible turns it into a perpetual-motion -// machine: every frame it asks for schedules the next 2 s timer, and the idle -// governor never gets to sleep. Two gates close that: the scene CAPABILITY -// (a scene that cannot sample heights must never arm) and a bounded budget of -// consecutive arms (a sampleable scene with no sampleable surface). - -/** Freeze performance.now so the 450 ms visibility gate is under test control. */ -function installFakeClock(t, startMs = 1_000_000) { - const original = performance.now; - let nowMs = startMs; - performance.now = () => nowMs; - t.after(() => { performance.now = original; }); - return { advance(ms) { nowMs += ms; } }; -} - -/** - * Run the self-armed retry chain: each preRender walk arms a timer, the timer - * asks the governor for a frame, and that frame is the next walk. - */ -function runArmedRetryChain(env, t, clock, cycles) { - for (let i = 0; i < cycles; i += 1) { - env.preRender.raise(); - clock.advance(2_100); // > GROUND_SAMPLE_RETRY_MS, in lockstep with the timers - t.mock.timers.tick(2_100); - } -} - -/** Height of the (mutated-in-place) stem base — 0 until a sample lands. */ -function baseHeightM(env) { - const entity = env.dataSources[0].entities.values[0]; - return Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian).height; -} - -/** - * Count Cartesian3.distance calls. The retry path costs one per ungrounded - * record per walk; on a scene that cannot sample, every one of them is waste - * (O(N) every 450 ms in a keyless scene that some other layer keeps awake). - */ -function countDistanceCalls(t) { - const original = Cesium.Cartesian3.distance; - const calls = { count: 0 }; - Cesium.Cartesian3.distance = (...args) => { - calls.count += 1; - return original(...args); - }; - t.after(() => { Cesium.Cartesian3.distance = original; }); - return calls; -} - -test('a scene that cannot sample heights never arms a retry — not even once', async (t) => { - // Keyless/no-sampleable-surface scene: sampleHeightSupported === false, so a - // retry can NEVER succeed. Arming here re-armed forever (one 2 s timer per - // requested frame) and quietly defeated the idle governor. - const env = await createRealLocalLayerHarness({ sampleHeightSupported: false }); - _resetRenderGovernorForTest(); - const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; - installRenderGovernor({ scene: governorScene }); - governorScene.renders = 0; // discard the governor's own install settling frame - t.mock.timers.enable({ apis: ['setTimeout'] }); - const clock = installFakeClock(t); - t.after(() => { - env.layer.destroy(env.viewer); - env.cleanup(); - _resetRenderGovernorForTest(); - }); - - setCameraAltitude(env, 20_000); // in range: the ONLY thing missing is the capability - // Settle the one-off geometry refresh (that walk legitimately measures the - // camera distance to size the stem), then measure the STEADY state. - env.preRender.raise(); - clock.advance(2_100); - const distances = countDistanceCalls(t); - runArmedRetryChain(env, t, clock, 5); - - assert.equal(governorScene.renders, 0, 'an unsampleable scene must ask for no frames at all'); - assert.ok( - !governorReasons().some((reason) => reason.startsWith('local-ground-retry')), - `no retry may be armed without the capability; saw ${JSON.stringify(governorReasons())}`, - ); - assert.equal(env.sampleCalls.count, 0, 'and it must not even attempt the sample'); - assert.equal( - distances.count, - 0, - 'nor spend a single per-record distance on a retry that cannot succeed', - ); - assert.equal(baseHeightM(env), 0, 'records stay at ellipsoid height — the pre-perf behavior'); -}); - -test('a sampleable scene still measures the retry distance it needs', async (t) => { - // The mirror of the guard above: the capability check must gate the work, not - // remove it — a scene that CAN sample still pays one distance per walk. - const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); - _resetRenderGovernorForTest(); - installRenderGovernor({ scene: { requestRender() {} } }); - t.mock.timers.enable({ apis: ['setTimeout'] }); - const clock = installFakeClock(t); - t.after(() => { - env.layer.destroy(env.viewer); - env.cleanup(); - _resetRenderGovernorForTest(); - }); - - setCameraAltitude(env, 20_000); - env.preRender.raise(); - clock.advance(2_100); - const distances = countDistanceCalls(t); - runArmedRetryChain(env, t, clock, 5); - - assert.ok(distances.count > 0, 'the retry path must still measure range when it can sample'); - assert.ok(env.sampleCalls.count > 0, 'and must still attempt the sample'); -}); - -test('capability arriving late re-opens a spent budget without camera motion', async (t) => { - // WebGL context restore / a tileset that only becomes sampleable later. A - // parked camera has no moveEnd to re-open the budget, so the false→true edge - // must do it — otherwise the layer stays permanently given-up. - const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); - _resetRenderGovernorForTest(); - const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; - installRenderGovernor({ scene: governorScene }); - governorScene.renders = 0; - t.mock.timers.enable({ apis: ['setTimeout'] }); - const clock = installFakeClock(t); - t.after(() => { - env.layer.destroy(env.viewer); - env.cleanup(); - _resetRenderGovernorForTest(); - }); - - setCameraAltitude(env, 20_000); - runArmedRetryChain(env, t, clock, GROUND_SAMPLE_MAX_ARMED_RETRIES + 2); - assert.equal(governorScene.renders, GROUND_SAMPLE_MAX_ARMED_RETRIES, 'budget spent'); - - // Context lost, then restored — with the camera never touched. - env.viewer.scene.sampleHeightSupported = false; - runArmedRetryChain(env, t, clock, 2); - assert.equal(governorScene.renders, GROUND_SAMPLE_MAX_ARMED_RETRIES, 'no arming while unsupported'); - - env.viewer.scene.sampleHeightSupported = true; - runArmedRetryChain(env, t, clock, 3); - assert.equal( - governorScene.renders, - GROUND_SAMPLE_MAX_ARMED_RETRIES + 3, - 'the false→true edge must re-open the budget on a parked camera', - ); -}); - -test('a sampleable scene that keeps failing gives up after a bounded run of arms', async (t) => { - // Supported, but nothing under the feature is sampleable: every retry fails. - const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); - _resetRenderGovernorForTest(); - const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; - installRenderGovernor({ scene: governorScene }); - governorScene.renders = 0; // discard the governor's own install settling frame - t.mock.timers.enable({ apis: ['setTimeout'] }); - const clock = installFakeClock(t); - t.after(() => { - env.layer.destroy(env.viewer); - env.cleanup(); - _resetRenderGovernorForTest(); - }); - - setCameraAltitude(env, 20_000); - runArmedRetryChain(env, t, clock, GROUND_SAMPLE_MAX_ARMED_RETRIES); - assert.equal( - governorScene.renders, - GROUND_SAMPLE_MAX_ARMED_RETRIES, - 'the chain must run its full budget before giving up', - ); - - // Budget spent: further parked frames retry the sample (free) but ask for - // nothing more — the idle governor is allowed to sleep. - runArmedRetryChain(env, t, clock, 10); - assert.equal( - governorScene.renders, - GROUND_SAMPLE_MAX_ARMED_RETRIES, - 'past the cap the layer must stop arming instead of re-arming forever', - ); -}); - -test('after the cap a camera-motion frame still samples, and re-opens the budget', async (t) => { - const env = await createRealLocalLayerHarness({ sampleHeightSupported: true }); - _resetRenderGovernorForTest(); - const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; - installRenderGovernor({ scene: governorScene }); - governorScene.renders = 0; // discard the governor's own install settling frame - t.mock.timers.enable({ apis: ['setTimeout'] }); - const clock = installFakeClock(t); - t.after(() => { - env.layer.destroy(env.viewer); - env.cleanup(); - _resetRenderGovernorForTest(); - }); - - setCameraAltitude(env, 20_000); - runArmedRetryChain(env, t, clock, GROUND_SAMPLE_MAX_ARMED_RETRIES + 3); - assert.equal(governorScene.renders, GROUND_SAMPLE_MAX_ARMED_RETRIES, 'budget spent'); - assert.equal(baseHeightM(env), 0, 'still ungrounded while the tiles were missing'); - - // The tiles finally arrive. Camera-motion frames are free (the user is - // already paying for them), so the walk they trigger must still sample. - env.setSampleHeight(() => 210); - setCameraAltitude(env, 19_000); - env.moveEnd.raise(); - clock.advance(2_100); - env.preRender.raise(); - - assert.ok( - Math.abs(baseHeightM(env) - 210) < 1e-6, - `the motion frame must ground the stem; base height ${baseHeightM(env)}`, - ); - - // Grounded, so there is nothing left to arm: no new frame requests either. - runArmedRetryChain(env, t, clock, 5); - assert.equal( - governorScene.renders, - GROUND_SAMPLE_MAX_ARMED_RETRIES, - 'a grounded record asks for no further frames', - ); -}); - -// ── Globe-LOD: bound the active-stem set by camera height ──────────────────── -// -// createLocalGeoJsonLayer used to give every feature a live stem and walk all -// of them (geometry trig + Cesium property writes) on every camera move. With -// three bundled-infra layers that is ~5,700 records on a full-earth view — the -// frame-rate cliff that got the INFRASTRUCTURE first-run tile cut. The walk -// now asks src/data/localGeojsonLod.js which records may carry a stem, sized to -// the camera-height budget, and skips all per-frame cost for the rest. - -/** - * Harness with N in-view point features and a controllable camera height. - * Even-indexed features are named (high label priority), odd ones anonymous. - */ -async function createMultiFeatureLodHarness({ featureCount = 150, cameraHeightM = 9_000_000 } = {}) { - const originalFetch = globalThis.fetch; - const originalWindow = globalThis.window; - const preRender = new MockLayerEvent(); - const moveEnd = new MockLayerEvent(); - const dataSources = []; - const centerLon = -97.7; - const centerLat = 30.2; - // Polygons, not Points: Cesium's GeoJsonDataSource builds a PinBuilder - // billboard for a Point, which needs a DOM canvas the node test env lacks. - // The layer takes the polygon's bounding-sphere centre as the stem anchor. - const lines = Array.from({ length: featureCount }, (_, i) => { - const lon = centerLon + (i % 12) * 0.02; - const lat = centerLat + Math.floor(i / 12) * 0.02; - return JSON.stringify({ - type: 'Feature', - id: `dc-${i}`, - properties: i % 2 === 0 - ? { name: `Datacenter ${i}`, tags: { name: `Datacenter ${i}`, operator: 'Example Cloud' } } - : { tags: {} }, - geometry: { - type: 'Polygon', - coordinates: [[ - [lon, lat], - [lon + 0.004, lat], - [lon + 0.004, lat + 0.004], - [lon, lat], - ]], - }, - }); - }); - globalThis.fetch = async () => ({ ok: true, status: 200, text: async () => lines.join('\n') }); - globalThis.window = { dispatchEvent() {} }; - - const setPos = (m) => { - const p = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, m); - viewer.camera.positionWC = p; - viewer.camera.positionCartographic = Cesium.Cartographic.fromCartesian(p); - }; - const viewer = { - selectedEntity: undefined, - dataSources: { - add(ds) { dataSources.push(ds); return ds; }, - remove(ds) { - const i = dataSources.indexOf(ds); - if (i >= 0) dataSources.splice(i, 1); - return i >= 0; - }, - }, - camera: { - positionWC: null, - positionCartographic: null, - frustum: { fov: Math.PI / 3 }, - moveEnd, - flyTo() {}, - }, - scene: { - canvas: { clientWidth: 1440, clientHeight: 900 }, - preRender, - sampleHeightSupported: false, - screenSpaceCameraController: { enableInputs: true }, - pick() { return null; }, - requestRender() {}, - }, - }; - setPos(cameraHeightM); - const layer = createLocalGeoJsonLayer({ - id: 'local-datacenters', - url: '/lod-fixture.geojsonl', - name: 'LOD Datacenters', - color: '#00ffff', - overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, - projectToWindow: () => ({ x: 700, y: 450 }), - screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }), - }); - try { - await layer.enable(viewer); - } finally { - globalThis.fetch = originalFetch; - } - return { - layer, - viewer, - dataSources, - preRender, - moveEnd, - setCameraHeight: setPos, - shownCount() { - return dataSources[0].entities.values.filter((entity) => entity.show === true).length; - }, - cleanup() { - if (originalWindow === undefined) delete globalThis.window; - else globalThis.window = originalWindow; - }, - }; -} - -test('globe-LOD caps live stems at the camera-height budget and widens as you zoom in', async (t) => { - const env = await createMultiFeatureLodHarness({ featureCount: 150, cameraHeightM: 9_000_000 }); - const clock = installFakeClock(t); - t.after(() => { - env.layer.destroy(env.viewer); - env.cleanup(); - }); - - // Full-earth framing: 150 in-view features, but the global band allows 80. - env.preRender.raise(); - assert.equal(env.dataSources[0].entities.values.length, 150, 'all features are materialized'); - assert.equal(env.shownCount(), INFRA_LOD_ACTIVE_MIN, 'only the global-band budget carries a stem'); - - // Named features (label priority ~1240) outrank anonymous nodes (~60), so - // every one of the 75 named features keeps a stem; the 5 remaining budget - // slots go to unnamed ones. - const shownIds = new Set( - env.dataSources[0].entities.values.filter((entity) => entity.show === true).map((entity) => entity.id), - ); - const namedIds = Array.from({ length: 150 }, (_, i) => i).filter((i) => i % 2 === 0).map((i) => `dc-${i}`); - assert.ok(namedIds.every((id) => shownIds.has(id)), 'every named feature wins a stem before any unnamed one'); - - // Zoom to continental framing: the budget opens to MID (200), so all 150 - // in-view features now get a stem. - env.setCameraHeight(1_000_000); - env.moveEnd.raise(); - clock.advance(500); // clear the 450 ms visibility gate - env.preRender.raise(); - assert.equal(env.shownCount(), 150, 'a closer camera lifts the cap above the in-view count'); -}); - -test('globe-LOD selection is stable between camera moves (no per-frame churn)', async (t) => { - const env = await createMultiFeatureLodHarness({ featureCount: 150, cameraHeightM: 9_000_000 }); - const clock = installFakeClock(t); - t.after(() => { - env.layer.destroy(env.viewer); - env.cleanup(); - }); - - env.preRender.raise(); - const firstSet = env.dataSources[0].entities.values - .filter((entity) => entity.show === true) - .map((entity) => entity.id) - .sort(); - assert.equal(firstSet.length, INFRA_LOD_ACTIVE_MIN); - - // A second walk with no intervening moveEnd must not re-select. - clock.advance(500); - env.preRender.raise(); - const secondSet = env.dataSources[0].entities.values - .filter((entity) => entity.show === true) - .map((entity) => entity.id) - .sort(); - assert.deepEqual(secondSet, firstSet, 'the active set only changes on camera moves'); -}); - -test('getLodDiagnostics reports the active/total split and the band budget', async (t) => { - const env = await createMultiFeatureLodHarness({ featureCount: 150, cameraHeightM: 9_000_000 }); - const clock = installFakeClock(t); - t.after(() => { - env.layer.destroy(env.viewer); - env.cleanup(); - }); - - assert.deepEqual(env.layer.getLodDiagnostics(), { - total: 150, active: 0, budgetLimit: 0, computed: false, - }, 'before the first walk: materialized but not yet selected'); - - env.preRender.raise(); - const global = env.layer.getLodDiagnostics(); - assert.equal(global.total, 150); - assert.equal(global.active, INFRA_LOD_ACTIVE_MIN); - assert.equal(global.budgetLimit, INFRA_LOD_ACTIVE_MIN); - assert.equal(global.computed, true); - assert.ok(global.active < global.total, 'active < total means the declutter is engaged'); - - env.setCameraHeight(60_000); - env.moveEnd.raise(); - clock.advance(500); - env.preRender.raise(); - const regional = env.layer.getLodDiagnostics(); - assert.equal(regional.active, 150, 'regional band lifts the cap above the in-view count'); - assert.ok(regional.budgetLimit >= 150, `regional budget widened (${regional.budgetLimit})`); -}); - -test('globe-LOD releases every stem when the layer is disabled', async (t) => { - const env = await createMultiFeatureLodHarness({ featureCount: 40, cameraHeightM: 9_000_000 }); - t.after(() => { - env.layer.destroy(env.viewer); - env.cleanup(); - }); - - env.preRender.raise(); - assert.equal(env.shownCount(), 40, 'under-budget: all 40 show'); - - env.layer.disable(env.viewer); - // The data source is hidden wholesale on disable; re-enabling starts from a - // fresh (empty) LOD selection rather than inheriting the old active set. - await env.layer.enable(env.viewer); - env.preRender.raise(); - assert.equal(env.shownCount(), 40, 're-enable rebuilds the selection cleanly'); -}); - -test('globe-LOD re-selects during continuous motion, without ever seeing a moveEnd', async (t) => { - // A tracked-entity follow, Cockpit view, route flight, or continuous orbit - // moves the camera indefinitely without emitting moveEnd. Before the motion - // fallback the active set stayed pinned to the region the camera left, and - // the walk hid those records as they passed behind the globe while admitting - // nothing newly visible — the layer bled down to sparse-or-empty until the - // motion stopped. - const env = await createMultiFeatureLodHarness({ featureCount: 150, cameraHeightM: 9_000_000 }); - const clock = installFakeClock(t); - t.after(() => { - env.layer.destroy(env.viewer); - env.cleanup(); - }); - - env.preRender.raise(); - assert.equal(env.layer.getLodDiagnostics().budgetLimit, INFRA_LOD_ACTIVE_MIN); - assert.equal(env.shownCount(), INFRA_LOD_ACTIVE_MIN, 'global band caps the first selection'); - - // The camera travels; NO moveEnd is raised, here or anywhere below. - env.setCameraHeight(60_000); - - // Inside the probe window the selection deliberately stays put: the fallback - // is rate-limited so continuous motion cannot recompute on every walk. - clock.advance(500); // > the 450 ms visibility gate, < the 1 s probe window - env.preRender.raise(); - assert.equal(env.layer.getLodDiagnostics().budgetLimit, INFRA_LOD_ACTIVE_MIN, - 'the probe window rate-limits the recompute'); - - // Past the window, the travelled camera re-selects on its own. - clock.advance(600); - env.preRender.raise(); - const moved = env.layer.getLodDiagnostics(); - assert.equal(moved.budgetLimit, INFRA_LOD_ACTIVE_MAX, 'the new camera height re-banded the budget'); - assert.equal(moved.active, 150, 'every in-view record is admitted again'); - assert.equal(env.shownCount(), 150, 'and actually carries a stem'); - - // A parked camera past the same window costs nothing: travel is measured - // from the last selection, so there is none to report. - const parked = env.layer.getLodDiagnostics(); - clock.advance(2_000); - env.preRender.raise(); - assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects'); -}); - diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 4a39a7a09..3ecf3fd51 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1,55 +1,24 @@ -import * as Cesium from 'cesium'; -import { - layerTitle, - mapAnalystRecord, - parseGeojsonLines, -} from '../sources/infrastructureData.js'; +// The datacenter and dam layers' engine-free parts: card copy and entries, +// the card cohort, the overlay publisher, label priorities and the dataset +// error text. They draw through src/layers/infrastructure/render/display.js. + +import { layerTitle, mapAnalystRecord } from '../sources/infrastructureData.js'; import { createInfrastructureOverlayEntry } from './infrastructureOverlayEntry.js'; -import { isPointerFree } from './inputOwnership.js'; -import { - selectInfraLod, - applyInfraEvictionGrace, - shouldRecomputeInfraLod, -} from './localGeojsonLod.js'; -const DEFAULT_LABEL_MAX = 900; -const DEFAULT_LABEL_GRID_PX = 132; -const VISIBILITY_UPDATE_MS = 450; // Each source keeps its own bounded cohort; the host sums their ambient-card // paint budgets only up to its 192-card shared-lane ceiling. export const LOCAL_OVERLAY_COHORT_LIMIT = 160; const LOCAL_OVERLAY_COLLISION_CAPACITY = 96; const LOCAL_OVERLAY_CELL_SURPLUS = 2; -// Stems are anchored at ellipsoid height 0, but high-elevation features -// (e.g. dams in river canyons) sit hundreds of meters above the ellipsoid, -// burying the short close-in stem inside the photoreal mesh. Once the -// camera is near enough for tiles to be loaded, sample the real surface -// height once per feature and lift the stem onto it. -const GROUND_SAMPLE_MAX_DISTANCE_M = 75000; -const GROUND_SAMPLE_RETRY_MS = 2000; -const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000; -/** - * Bounded give-up for the self-armed retry. Sampling can be SUPPORTED and still - * never succeed (no sampleable surface under the feature), in which case each - * requested frame would arm the next 2 s timer forever — an idle-governor leak - * dressed up as a retry. After this many consecutive armed retries with no - * record newly grounded, stop arming; camera motion (a frame we get for free) - * still retries through the normal preRender walk and re-opens the budget. - * 30 × 2 s ≈ 60 s, far longer than a tile stream-in. - */ -export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30; -/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */ -export const LOCAL_STEM_TIP_EPSILON_M = 0.5; -const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2; /** * Build the owner-approved local-infrastructure card copy. - * @param {object} properties Unwrapped GeoJSON feature properties. + * @param {object} properties GeoJSON feature properties. * @param {string} layerId Local layer id. * @returns {{title:string,details:string[]}} */ export function localInfrastructureOverlayCopy(properties, layerId) { - const props = unwrapProperties(properties) || {}; + const props = properties || {}; const tags = props.tags || {}; const title = featureLabelFromProperties(props, layerId); const details = []; @@ -97,8 +66,8 @@ export function localInfrastructureOverlayCopy(properties, layerId) { * @param {object} options * @param {string} options.id Stable id within the source. * @param {string} options.layerId Local layer id. - * @param {Cesium.Cartesian3} options.position Current stem-tip position. - * @param {object} options.properties Unwrapped feature properties. + * @param {{x:number,y:number,z:number}} options.position Current stem-tip position. + * @param {object} options.properties Feature properties. * @param {number} options.priority Source-owned importance score. * @param {string} options.accent Source accent color. * @returns {object} @@ -288,822 +257,6 @@ export function localDatasetError(error) { return message ? `dataset unavailable (${message})` : 'dataset unavailable'; } -/** - * A minimal, rock-solid native implementation for loading local GeoJSON Data. - * Draws 3D stems (polylines) attached to Point entities and ensures - * standard scene.pick natively clicks them. - * @param {object} options Dataset URL, identity, appearance and optional Cesium adapters. - * @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md. - * @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords. - * One live instance per layer id is allowed in a given viewer/context/overlay host. - * Destroy the previous instance before replacing it. Importing creates no layers. - */ -export function createLocalGeoJsonLayer( - { - id, - url, - name, - color, - icon = '📍', - source = 'Local JSONL', - osmDerived = false, - readFeatures = null, - labels = true, - labelMax = DEFAULT_LABEL_MAX, - labelGridPx = DEFAULT_LABEL_GRID_PX, - screenSpaceEventHandlerFactory = (canvas) => - new Cesium.ScreenSpaceEventHandler(canvas), - projectToWindow = (scene, position) => - Cesium.SceneTransforms.worldToWindowCoordinates(scene, position), - }, - { - overlayHost, - registerEntityContext, - selectEntityContext, - clearSelectedEntityContextForLayer, - removeEntityContextsForLayer, - governorRequestRender, - showOsmCredit, - hideOsmCredit, - }, -) { - let _dataSource = null; - let _enabled = false; - let _clickHandler = null; - let _count = 0; - /** @type {number|null} Timestamp of the last successful dataset load. */ - let _lastUpdate = null; - /** @type {string|null} Short reason the bundled dataset failed to load. */ - let _error = null; - let _preRenderRemover = null; - let _cameraMoveEndRemover = null; - let _stemRecords = []; - let _stemGeometryDirty = true; - let _lastVisibilityUpdate = 0; - let _destroyed = false; - let _loadPromise = null; - let _loadController = null; - /** - * The parsed bundled dataset, kept across disable/enable so a re-enable - * rebuilds entities without refetching. The Cesium entities themselves are - * NOT kept: a hidden data source still costs every frame (DataSourceDisplay - * walks every visualizer over every entity regardless of `show`), and the - * ~11k entities of the three bundled layers hold hundreds of MB, so leaving - * them parked after a toggle-off made the whole scene render ~2× slower for - * the rest of the session (owner field test 2026-09-13). - * @type {Array|null} - */ - let _cachedFeatures = null; - /** - * Globe-LOD active set: the record ids allowed to carry a live stem right - * now. This bounds geometry refreshes and ground-sample work to the - * camera-height budget in localGeojsonLod.js. Recomputed only - * when the camera moves (`_stemGeometryDirty`, raised by moveEnd and by the - * motion fallback below). Empty means "not computed yet" — the first - * post-enable walk fills it. - * @type {Set} - */ - let _activeLodIds = new Set(); - /** Eviction-grace bookkeeping for `_activeLodIds` (id -> {misses, since}). */ - let _lodGraceState = new Map(); - /** Budget cap the most recent camera-height band produced (0 until computed). */ - let _lastLodBudgetLimit = 0; - /** - * False until the first post-enable preRender walk has run the LOD - * selection. While false the walk treats every record as active (old - * behavior); once true, an EMPTY `_activeLodIds` legitimately means "nothing - * should carry a stem right now" (e.g. every feature occluded), not "not - * computed yet". - */ - let _lodComputed = false; - /** - * Camera position the last LOD selection ran from — the reference the motion - * fallback measures travel against. Preallocated: the walk clones into it, - * so a moving camera allocates nothing per pass. Only meaningful while - * `_lodComputed` is true. - */ - const _lastLodCameraPos = new Cesium.Cartesian3(); - /** Last time the motion-fallback probe window opened (or a selection ran). */ - let _lastLodProbeMs = Number.NEGATIVE_INFINITY; - let _groundRetryTimer = null; - /** Consecutive self-armed retries since the last grounding/camera motion. */ - let _groundRetryArms = 0; - /** Last observed scene.sampleHeightSupported; null until the first walk. */ - let _lastGroundSampleCapability = null; - - /** - * Coalesced one-shot: ask the governor for a frame once the retry window - * has elapsed, so the preRender ground-sample retry actually runs while the - * camera is parked. One timer for the whole layer (not per record) — the - * retry pass walks every record anyway. (perf rebase 2026-08-17) - * - * Two gates keep this from becoming an idle leak (review round 2): - * - CAPABILITY: without `scene.sampleHeightSupported` the sample can never - * succeed, so a timer here would re-arm on every requested frame, - * forever. Records simply stay at ellipsoid height — exactly the - * pre-perf keyless behavior. - * - BUDGET: sampling can be supported and still keep failing (no sampleable - * surface yet/ever). Give up after GROUND_SAMPLE_MAX_ARMED_RETRIES - * consecutive arms; free camera-motion frames still retry. - * @param {Cesium.Viewer} viewer - * @returns {void} - */ - function scheduleGroundRetryRender(viewer) { - if (_groundRetryTimer || !_enabled) return; - if (!viewer?.scene?.sampleHeightSupported) return; - if (_groundRetryArms >= GROUND_SAMPLE_MAX_ARMED_RETRIES) return; - _groundRetryArms += 1; - _groundRetryTimer = setTimeout(() => { - _groundRetryTimer = null; - if (!_enabled || _destroyed) return; - governorRequestRender(`local-ground-retry:${id}`); - }, GROUND_SAMPLE_RETRY_MS); - } - - function clearGroundRetryRender() { - _groundRetryArms = 0; - _lastGroundSampleCapability = null; - if (!_groundRetryTimer) return; - clearTimeout(_groundRetryTimer); - _groundRetryTimer = null; - } - const _overlayPublisher = createLocalInfrastructureOverlayPublisher({ - sourceId: id, - host: overlayHost, - }); - - /** - * Take the built entities out of the scene entirely. The parsed features - * stay cached, so the next enable() rebuilds without a fetch; what must not - * survive a disable is the per-frame visualizer walk and the entity memory. - * @param {object} viewer - */ - const releaseDataSource = (viewer) => { - if (!_dataSource) return; - const source = _dataSource; - _dataSource = null; - _stemRecords = []; - _stemGeometryDirty = true; - _lastVisibilityUpdate = Number.NEGATIVE_INFINITY; - removeEntityContextsForLayer(id); - try { - viewer?.dataSources?.remove(source, true); - } catch { - /* already gone */ - } - }; - - const disableLayer = (viewer) => { - hideOsmCredit?.(viewer, id); - _enabled = false; - clearGroundRetryRender(); - _activeLodIds = new Set(); - _lodGraceState = new Map(); - _lastLodBudgetLimit = 0; - _lodComputed = false; - _lastLodProbeMs = Number.NEGATIVE_INFINITY; - releaseDataSource(viewer); - _overlayPublisher.hide(); - clearSelectedEntityContextForLayer(id); - if (viewer?.selectedEntity?.__localLayerId === id) { - viewer.selectedEntity = undefined; - } - if (_preRenderRemover) { - _preRenderRemover(); - _preRenderRemover = null; - } - if (_cameraMoveEndRemover) { - _cameraMoveEndRemover(); - _cameraMoveEndRemover = null; - } - }; - - return { - id, - name, - icon, - source, - updateInterval: 0, - statsRefreshInterval: 1000, - - init: async (viewer) => { - // DataLayerManager calls this once - }, - - update: async (viewer) => { - // DataLayerManager calls this when enabled - }, - - /** - * @returns {{count:number, lastUpdate:number|null, error:string|null}} - * A dead layer must be distinguishable from an empty one: a failed load - * surfaces `error` (manager chip → UNAVAILABLE) instead of reporting a - * silent zero count as nominal. - */ - getStats: () => { - return { count: _count, lastUpdate: _lastUpdate, error: _error }; - }, - - /** - * Globe-LOD state for QA harnesses (scripts/qa-infra-lod.mjs) and tests. - * `active` is how many records currently carry a live stem; `total` is the - * full materialized set. `active < total` means the declutter is engaged. - * `budgetLimit` is the cap the last camera-height band produced; `computed` - * is false until the first post-enable walk has run the selection. - * @returns {{total:number, active:number, budgetLimit:number, computed:boolean}} - */ - getLodDiagnostics: () => ({ - total: _stemRecords.length, - active: _activeLodIds.size, - budgetLimit: _lastLodBudgetLimit, - computed: _lodComputed, - }), - - /** - * Snapshot in-memory infrastructure features as plain JSON-safe objects - * for the analyst query engine. On-demand only (called at most once per - * spoken query) — zero per-frame cost, no listeners, no caching. Returns - * [] while the layer is disabled or empty. Disable keeps the loaded - * stems for reuse; this method still returns [] until the next enable. - * @param {number} [maxCount=2000] Maximum records to return (truncation). - * @returns {Array} See mapAnalystRecord for the record shape. - */ - getAnalystRecords(maxCount = 2000) { - if (!_enabled || !_stemRecords.length) return []; - const limit = Number.isFinite(maxCount) - ? Math.max(1, Math.floor(maxCount)) - : 2000; - const result = []; - for (let i = 0; i < _stemRecords.length; i++) { - if (result.length >= limit) break; - const record = _stemRecords[i]; - const carto = record.carto; - result.push( - mapAnalystRecord( - { - id: record.id, - lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null, - lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null, - properties: propertyObject(record.entity), - }, - id, - ), - ); - } - return result; - }, - - enable: async (viewer) => { - if (_destroyed) return; - _enabled = true; - _stemGeometryDirty = true; - _lastVisibilityUpdate = Number.NEGATIVE_INFINITY; - _groundRetryArms = 0; // fresh give-up budget per enable-cycle - _lastGroundSampleCapability = null; - _activeLodIds = new Set(); // fresh globe-LOD selection per enable-cycle - _lodGraceState = new Map(); - _lastLodBudgetLimit = 0; - _lodComputed = false; - _lastLodProbeMs = Number.NEGATIVE_INFINITY; - _overlayPublisher.show(); - - // 1. Initialize data source - if (!_dataSource) { - if (!_loadPromise) - _loadPromise = (async () => { - _loadController = new AbortController(); - const baseColor = Cesium.Color.fromCssColorString(color); - - // Fetch and parse JSON Lines (.geojsonl) into a FeatureCollection. - // The source is built into a local and committed to `_dataSource` - // only once setup finishes: a half-built source published early would - // make every later enable() skip this block, so the layer could never - // clear its error or retry. - _error = null; - let loaded = null; - // Whether the scene has actually accepted `loaded` — the two rollback - // windows (before vs after the add settles) need different cleanup. - let addedToScene = false; - try { - let features = - _cachedFeatures ?? (readFeatures ? await readFeatures() : null); - if (_destroyed) return; - if (!features) { - const response = await fetch(url, { - signal: _loadController.signal, - }); - if (_destroyed) return; - // A 404 returns an HTML body that would otherwise die in JSON.parse - // one line later, reported as a parse error for a missing file. - if (!response.ok) { - throw new Error(`HTTP ${response.status ?? '?'}`); - } - const text = await response.text(); - if (_destroyed) return; - features = parseGeojsonLines(text); - _cachedFeatures = features; - } - - const geojson = { - type: 'FeatureCollection', - features, - }; - - // Natively parse into entities and use it as our _dataSource - loaded = await Cesium.GeoJsonDataSource.load(geojson, { - clampToGround: true, - stroke: baseColor, - fill: baseColor.withAlpha(0.3), - strokeWidth: 2, - markerSize: 8, - markerColor: baseColor, - }); - - if (_destroyed) return; - loaded.name = name; - loaded.show = false; - // Cesium's DataSourceCollection.add() returns a promise and only - // inserts on a later microtask. Without this await, a throw during - // post-processing would roll back a source the scene had not - // accepted yet — and Cesium would then insert the "removed" source - // anyway, leaving an orphan the retry would double up on. Awaiting - // also routes an add() rejection into the error path below instead - // of leaving it uncaught with healthy-looking stats. - await viewer.dataSources.add(loaded); - addedToScene = true; - if (_destroyed) { - viewer.dataSources.remove(loaded, true); - return; - } - - // Convert parsed points into 3D stems or style polygons - const entities = loaded.entities.values; - _count = entities.length; - _stemRecords = []; - _stemGeometryDirty = true; - - for (let i = 0; i < entities.length; i++) { - const feature = entities[i]; - feature.__localLayerId = id; // Tag it so our click handler knows it belongs to this layer - - let pos = feature.position?.getValue(Cesium.JulianDate.now()); - - if (!pos) { - // It's a polygon or line - if (feature.polygon) { - feature.polygon.outline = true; - feature.polygon.outlineColor = baseColor; - - // Calculate center point for the stem - const hierarchy = feature.polygon.hierarchy?.getValue( - Cesium.JulianDate.now(), - ); - if ( - hierarchy && - hierarchy.positions && - hierarchy.positions.length > 0 - ) { - pos = Cesium.BoundingSphere.fromPoints( - hierarchy.positions, - ).center; - } - } - } - - if (!pos) continue; - - const carto = Cesium.Cartographic.fromCartesian(pos); - const groundHeight = 0; // Ellipsoid surface until a scene sample lands - const tipHeight = 2000; // Initial Stem height - - const base = Cesium.Cartesian3.fromRadians( - carto.longitude, - carto.latitude, - groundHeight, - ); - const tip = Cesium.Cartesian3.fromRadians( - carto.longitude, - carto.latitude, - tipHeight, - ); - const properties = propertyObject(feature); - const recordId = String(feature.id ?? i); - - // Store references for bounded stem scaling and native picking. - feature.__localBaseCarto = carto; - feature.__localBaseCartesian = base; - registerEntityContext(feature, { - id: `${id}:${recordId}`, - layerId: id, - layerName: name, - source, - dataSource: loaded, - label: featureLabelFromProperties(properties, id), - properties, - latitude: Number( - Cesium.Math.toDegrees(carto.latitude).toFixed(6), - ), - longitude: Number( - Cesium.Math.toDegrees(carto.longitude).toFixed(6), - ), - }); - - // Constant properties are refreshed on the existing 450 ms source - // cadence. Cesium no longer evaluates 2-3 callbacks per entity on - // every frame, while the point/stem pick surface stays native. - feature.position = tip; - const stemPositionBuffers = [ - [base, tip], - [base, tip], - ]; - feature.polyline = new Cesium.PolylineGraphics({ - positions: stemPositionBuffers[0], - width: 3.5, - material: new Cesium.ColorMaterialProperty(baseColor), - }); - feature.point = new Cesium.PointGraphics({ - pixelSize: 10, - color: baseColor, - outlineColor: Cesium.Color.BLACK, - outlineWidth: 2, - // Never depth-cull the anchor against the photoreal mesh — - // globe-horizon culling is handled by the pre-render occluder. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }); - - const priority = labelPriorityFromProperties(properties, id); - _stemRecords.push({ - id: recordId, - entity: feature, - carto, - base, - tip, - nextTip: Cesium.Cartesian3.clone(tip), - stemPositionBuffers, - stemPositionBufferIndex: 0, - groundHeight, - groundSampled: false, - lastGroundSampleMs: 0, - priority, - entry: labels - ? createLocalInfrastructureOverlayEntry({ - id: recordId, - layerId: id, - position: tip, - properties, - priority, - accent: color, - }) - : null, - }); - } - // Setup finished — publish it. - _dataSource = loaded; - _lastUpdate = Date.now(); - } catch (e) { - // The dataset ships with the build, so this is a broken install, - // not a blip — it has to reach the chip, not just the console. - if (!_destroyed) _error = localDatasetError(e); - // Roll the partial build back so a later enable() retries from - // scratch instead of inheriting a half-populated source. Only the - // post-add window has something in the scene to remove: a failure - // before (or inside) add() never reached the collection, and - // removing then would race Cesium's pending insert. - if (addedToScene) { - try { - viewer?.dataSources?.remove(loaded, true); - } catch { - /* already gone */ - } - } - if (_destroyed) return; - removeEntityContextsForLayer(id); - _count = 0; - _stemRecords = []; - console.error(`Failed to load ${id}:`, e); - } - - if (_destroyed) return; - // 2. Install native global click handler - if (!_clickHandler) { - _clickHandler = screenSpaceEventHandlerFactory( - viewer.scene.canvas, - ); - _clickHandler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js). - if (!isPointerFree()) return; - if (!_enabled) return; - const picked = viewer.scene.pick(click.position); - - if (picked && picked.id && picked.id.__localLayerId === id) { - const entity = picked.id; - viewer.selectedEntity = entity; - selectEntityContext(entity); - - // We zoom to the surface base of the stem or the center of the polygon - let targetPos = null; - - if (entity.polyline) { - // If it's a stem, fly to the base - const positions = entity.polyline.positions.getValue( - Cesium.JulianDate.now(), - ); - if (positions && positions.length > 0) { - targetPos = positions[0]; - } - } else if (entity.polygon && entity.polygon.hierarchy) { - // If it's a polygon, just fly to its center - const hierarchy = entity.polygon.hierarchy.getValue( - Cesium.JulianDate.now(), - ); - if (hierarchy && hierarchy.positions.length > 0) { - targetPos = Cesium.BoundingSphere.fromPoints( - hierarchy.positions, - ).center; - } - } - - if (targetPos) { - const carto = Cesium.Cartographic.fromCartesian(targetPos); - - // Disable interactions so Cesium doesn't magically cancel the flight - viewer.scene.screenSpaceCameraController.enableInputs = false; - - viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromRadians( - carto.longitude, - carto.latitude, - 5000, - ), - duration: 1.5, - complete: () => { - viewer.scene.screenSpaceCameraController.enableInputs = true; - }, - cancel: () => { - viewer.scene.screenSpaceCameraController.enableInputs = true; - }, - }); - } - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - } - })(); - try { - await _loadPromise; - } finally { - _loadPromise = null; - _loadController = null; - } - } - - if (_destroyed) return; - if (_enabled && _count > 0 && osmDerived) showOsmCredit?.(viewer, id); - // 3. Add an incredibly fast pre-render occluder to hide points behind the globe - if (_enabled && !_preRenderRemover) { - _preRenderRemover = viewer.scene.preRender.addEventListener(() => { - if (!_enabled || !_dataSource) return; - const now = performance.now(); - if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) return; - _lastVisibilityUpdate = now; - - const cameraPos = viewer.camera.positionWC; - if (!cameraPos) return; - - // --- Globe-LOD motion fallback. --- - // `_stemGeometryDirty` is otherwise raised only by moveEnd, which a - // tracked-entity follow, Cockpit, route flight, or continuous orbit - // never emits. Left alone the selection stays pinned to the region - // the camera left: the walk below hides those records one by one as - // they pass behind the globe and admits nothing newly visible, so - // the layer bleeds down to sparse-or-empty until motion stops. - // Bounded: rate-limited to a probe window, and only when the camera - // has actually travelled since the last selection. - if (!_stemGeometryDirty && _lodComputed && _stemRecords.length > 0) { - const probe = shouldRecomputeInfraLod({ - nowMs: now, - lastProbeMs: _lastLodProbeMs, - movedSqM: Cesium.Cartesian3.distanceSquared( - cameraPos, - _lastLodCameraPos, - ), - cameraHeightM: viewer.camera.positionCartographic?.height, - }); - _lastLodProbeMs = probe.lastProbeMs; - // Re-selecting demands fresh stem geometry: a record admitted - // mid-motion has never had its tip sized for this camera. - if (probe.recompute) _stemGeometryDirty = true; - } - - const occluder = new Cesium.EllipsoidalOccluder( - Cesium.Ellipsoid.WGS84, - cameraPos, - ); - const visibleOverlayRecords = []; - const refreshStemGeometry = _stemGeometryDirty; - - // A scene that cannot sample heights can never ground a record, so it - // must never arm a retry (the arm would re-arm on every requested - // frame, forever) and must not spend ANY per-record work trying. - // Read once per walk, not per record. - const canSampleGround = viewer.scene.sampleHeightSupported === true; - // Capability can arrive late (WebGL context restore, a tileset that - // finally supports sampling). A parked camera has no moveEnd to - // re-open a spent budget, so the false→true edge does it. - if (canSampleGround && _lastGroundSampleCapability === false) - _groundRetryArms = 0; - _lastGroundSampleCapability = canSampleGround; - let groundRetryPending = false; - let groundSampleProgress = false; - - // --- Globe-LOD: recompute the active-stem set on camera moves. --- - // `_stemGeometryDirty` is set by moveEnd, the first enable, and the - // motion fallback above — exactly when the camera-height budget and - // per-record distances can have changed. Without this, all local - // infrastructure features run stem trig + Cesium property writes on - // every move; with it, only the ~80 (global) to ~420 (regional) - // budgeted records do. - // The occluder test is the same cheap dot product used below. - if (refreshStemGeometry && _stemRecords.length > 0) { - const cameraHeightM = viewer.camera.positionCartographic?.height; - const candidates = new Array(_stemRecords.length); - for (let i = 0; i < _stemRecords.length; i++) { - const record = _stemRecords[i]; - candidates[i] = { - id: record.id, - priority: record.priority, - distanceM: Cesium.Cartesian3.distance(cameraPos, record.base), - inView: occluder.isPointVisible(record.base), - }; - } - const selection = selectInfraLod(candidates, { - cameraHeightM, - incumbentIds: _activeLodIds, - }); - const grace = applyInfraEvictionGrace({ - selectedIds: selection.activeIds, - builtIds: [..._activeLodIds], - graceState: _lodGraceState, - nowMs: now, - activeLimit: selection.budget.activeLimit, - }); - _activeLodIds = new Set(grace.keepIds); - _lodGraceState = grace.graceState; - _lastLodBudgetLimit = selection.budget.activeLimit; - _lodComputed = true; - // Adopt this pass as the motion-fallback reference. Travel is - // measured from the last SELECTION, never the previous walk, so - // jitter around a parked camera never accumulates into a - // recompute while genuine slow travel eventually does. - Cesium.Cartesian3.clone(cameraPos, _lastLodCameraPos); - _lastLodProbeMs = now; - } - - for (let i = 0; i < _stemRecords.length; i++) { - const record = _stemRecords[i]; - const isActive = !_lodComputed || _activeLodIds.has(record.id); - const isVisible = isActive && occluder.isPointVisible(record.base); - if (record.entity.show !== isVisible) - record.entity.show = isVisible; - - // Records outside the globe-LOD active set carry no visible stem, - // so skip every per-frame cost for them — geometry trig, the - // ground-sample GPU readback, overlay-label candidacy. They rejoin - // on the next camera move if the budget has room. - if (!isActive) continue; - - const wasGroundSampled = record.groundSampled; - const terrainFloorChanged = refreshLocalTerrainFloor( - viewer, - record, - ); - if (refreshStemGeometry || terrainFloorChanged) { - updateLocalStemGeometry(viewer, record, now); - } else if ( - canSampleGround && - !record.groundSampled && - now - record.lastGroundSampleMs >= GROUND_SAMPLE_RETRY_MS - ) { - // Capability first: without it the distance below is pure waste, - // once per ungrounded record per walk, forever. - const distance = Cesium.Cartesian3.distance( - viewer.camera.positionWC, - record.base, - ); - if ( - distance < GROUND_SAMPLE_MAX_DISTANCE_M && - sampleLocalGroundHeight(viewer, record, now) - ) { - updateLocalStemGeometry(viewer, record, now, distance); - } - } - if (!wasGroundSampled && record.groundSampled) - groundSampleProgress = true; - // Still unsampled AND close enough for a retry to succeed: this - // layer has no hold and no periodic update, so under the idle - // governor the retry's preRender never arrives on a parked camera - // and the stem stays at ellipsoid height (buried/floating) until - // the user happens to move. Schedule the frame the retry needs. - // Gated on a sampleable scene and in-range, in-view records only, - // so a far camera (or a keyless scene) stays fully idle; the - // distance is only computed for still-unsampled stems. - if ( - canSampleGround && - !record.groundSampled && - !groundRetryPending && - Cesium.Cartesian3.distance( - viewer.camera.positionWC, - record.base, - ) < GROUND_SAMPLE_MAX_DISTANCE_M && - groundSampleInView(viewer, record) - ) { - groundRetryPending = true; - } - if (isVisible && record.entry) visibleOverlayRecords.push(record); - } - _stemGeometryDirty = false; - // Tiles ARE streaming in: real progress re-opens the give-up budget - // so the records still waiting get their own bounded run of retries. - if (groundSampleProgress) _groundRetryArms = 0; - if (groundRetryPending) scheduleGroundRetryRender(viewer); - - const canvas = viewer.scene.canvas; - const cohort = selectLocalInfrastructureOverlayCohort( - visibleOverlayRecords, - { - maxEntries: labelMax, - gridPx: labelGridPx, - width: canvas.clientWidth || canvas.width || 0, - height: canvas.clientHeight || canvas.height || 0, - cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT, - project: (record) => projectToWindow(viewer.scene, record.tip), - }, - ); - _overlayPublisher.publish(cohort); - }); - } - if (_enabled && !_cameraMoveEndRemover) { - _cameraMoveEndRemover = viewer.camera.moveEnd.addEventListener(() => { - if (!_enabled) return; - _stemGeometryDirty = true; - _lastVisibilityUpdate = Number.NEGATIVE_INFINITY; - // Real camera motion is a fresh situation (new tiles, new distances) - // and its frames are free, so it re-opens the retry budget that a - // parked camera may have spent. - _groundRetryArms = 0; - viewer.scene.requestRender?.(); - }); - } - - // Honor a disable() that landed while we were awaiting the fetch/parse: - // disable() runs before _dataSource exists, so its release is a no-op — - // release the finished build here instead of parking it hidden in the - // scene (the parsed features stay cached for the next enable). - if (!_enabled) { - releaseDataSource(viewer); - return; - } - if (_dataSource) _dataSource.show = true; - viewer.scene.requestRender?.(); - }, - - restoreFeatureSelection(viewer, featureId) { - const entity = _dataSource?.entities.values.find( - (item) => String(item.id) === featureId, - ); - if (!entity) return false; - viewer.selectedEntity = entity; - selectEntityContext(entity); - return true; - }, - - disable: disableLayer, - - destroy: (viewer) => { - if (_destroyed) return; - _destroyed = true; - _loadController?.abort(); - // Defensively disable first so listeners and selection state are - // torn down even if destroy is called while the layer is enabled. - disableLayer(viewer); - removeEntityContextsForLayer(id); - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_dataSource && viewer) { - viewer.dataSources.remove(_dataSource, true); - } - _overlayPublisher.destroy(); - _dataSource = null; - _cachedFeatures = null; - _stemRecords = []; - _count = 0; - _lastUpdate = null; - _error = null; - }, - }; -} - function compareLocalOverlayRecords(a, b) { return b.priority - a.priority || String(a.id).localeCompare(String(b.id)); } @@ -1121,189 +274,6 @@ function insertLocalCellContender(contenders, record) { contenders.length = LOCAL_OVERLAY_CELL_SURPLUS; } -function refreshLocalTerrainFloor(viewer, record) { - const globe = viewer.scene.globe; - if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false) - return false; - if ( - Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base) >= - GROUND_SAMPLE_MAX_DISTANCE_M - ) - return false; - // Camera motion can refine terrain after the first successful depth sample. - // Reuse the loaded mesh on existing bounded walks; never arm a timer or do - // another GPU readback just to maintain this floor. Keep roof elevations. - const height = globe.getHeight?.(record.carto); - if ( - !Number.isFinite(height) || - Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M || - height <= record.groundHeight - ) - return false; - setLocalGroundHeight(record, height); - return true; -} - -// Made on first use: on a page that starts on Navara the Cesium SDK loads -// only with the first Cesium globe, after this module. -let groundScratch = null; -const groundSample = () => - (groundScratch ??= { - occluder: new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84), - carto: new Cesium.Cartographic(), - low: new Cesium.Cartesian3(), - high: new Cesium.Cartesian3(), - }); - -/** The record's vertical at `height` metres above the ellipsoid. */ -function groundSamplePoint(record, height, result) { - const { carto } = groundSample(); - carto.longitude = record.carto.longitude; - carto.latitude = record.carto.latitude; - carto.height = height; - return Cesium.Cartographic.toCartesian(carto, Cesium.Ellipsoid.WGS84, result); -} - -/** - * Whether the camera can see the ground under the record: inside the view - * frustum and above the horizon. scene.sampleHeight reads only surface - * rendered for the current view, so a sample of unseen ground passes through - * the Earth (about -6,344 km near Ashburn on Esri) and fails on every 2 s - * retry, each one a full offscreen render; 110 unseen data centres there held - * rest frames at 58-589 ms. Unsampled bases sit on the ellipsoid, so the test - * uses loaded globe terrain when there is some. Otherwise (a hidden globe) it - * tests the vertical across +/-GROUND_SAMPLE_MAX_ABS_HEIGHT_M, every height a - * sample can accept, so high or below-ellipsoid ground in view is never - * mistaken for an unseen base. Unseen ground is sampled on the first walk that - * sees it. - */ -function groundSampleInView(viewer, record) { - const scratch = groundSample(); - const camera = viewer.camera; - const globe = viewer.scene.globe; - const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined; - const known = - Number.isFinite(terrain) && - Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M; - const low = groundSamplePoint( - record, - known ? terrain : -GROUND_SAMPLE_MAX_ABS_HEIGHT_M, - scratch.low, - ); - const high = known - ? low - : groundSamplePoint(record, GROUND_SAMPLE_MAX_ABS_HEIGHT_M, scratch.high); - // The vertical is unseen only when both ends are outside the same plane. - const { planes } = camera.frustum.computeCullingVolume( - camera.positionWC, - camera.directionWC, - camera.upWC, - ); - for (const plane of planes) { - if ( - Cesium.Cartesian3.dot(plane, low) + plane.w < 0 && - Cesium.Cartesian3.dot(plane, high) + plane.w < 0 - ) - return false; - } - // The ellipsoidal occluder hides every point below the ellipsoid from a - // camera near it, so ground below 0 m (low-geoid coasts, the Dead Sea) is - // tested at 0 m; unknown ground at the top of the vertical. - const horizonHeight = known - ? Math.max(terrain, 0) - : GROUND_SAMPLE_MAX_ABS_HEIGHT_M; - scratch.occluder.cameraPosition = camera.positionWC; - return scratch.occluder.isPointVisible( - groundSamplePoint(record, horizonHeight, scratch.high), - ); -} - -function sampleLocalGroundHeight(viewer, record, now) { - if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false; - if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false; - if (!groundSampleInView(viewer, record)) return false; - record.lastGroundSampleMs = now; - const globe = viewer.scene.globe; - if (globe?.show && globe.tilesLoaded === false) return false; - let sampled; - try { - sampled = viewer.scene.sampleHeight(record.carto, [record.entity]); - } catch { - return false; // tiles not ready; retry on a later bounded update - } - if ( - !Number.isFinite(sampled) || - Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M - ) - return false; - // A coarse scene-depth sample can lie below the terrain already loaded by - // the visible globe. Keep that terrain as a floor while allowing roofs and - // valid below-sea-level elevations. Photoreal scenes hide the globe, so its - // inactive terrain must not constrain their geometry. - const terrainHeight = globe?.show - ? globe.getHeight?.(record.carto) - : undefined; - if ( - Number.isFinite(terrainHeight) && - Math.abs(terrainHeight) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M - ) { - sampled = Math.max(sampled, terrainHeight); - } - record.groundSampled = true; - setLocalGroundHeight(record, sampled); - return true; -} - -function setLocalGroundHeight(record, height) { - record.groundHeight = height; - Cesium.Cartesian3.fromRadians( - record.carto.longitude, - record.carto.latitude, - record.groundHeight, - Cesium.Ellipsoid.WGS84, - record.base, - ); - record.entity.__localBaseCartesian = record.base; -} - -function updateLocalStemGeometry(viewer, record, now, knownDistance = null) { - const distance = Number.isFinite(knownDistance) - ? knownDistance - : Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base); - if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) - sampleLocalGroundHeight(viewer, record, now); - // Keep the intended screen-size scaling in close-up views too. A 5 km - // minimum made a marker hundreds of metres tall beside a nearby building. - const effectiveDistance = Math.max(distance, 1); - const canvasHeight = viewer.scene.canvas.clientHeight || 1080; - const fov = viewer.camera.frustum.fov || Math.PI / 3; - const targetPx = 65; - const fovFactor = 2 * Math.tan(fov / 2) * (targetPx / canvasHeight); - const tipHeight = record.groundHeight + effectiveDistance * fovFactor; - Cesium.Cartesian3.fromRadians( - record.carto.longitude, - record.carto.latitude, - tipHeight, - Cesium.Ellipsoid.WGS84, - record.nextTip, - ); - if ( - Cesium.Cartesian3.distanceSquared(record.tip, record.nextTip) <= - LOCAL_STEM_TIP_EPSILON_SQ - ) { - return false; - } - Cesium.Cartesian3.clone(record.nextTip, record.tip); - record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex; - const stemPositions = - record.stemPositionBuffers[record.stemPositionBufferIndex]; - stemPositions[0] = record.base; - stemPositions[1] = record.tip; - record.entity.position.setValue(record.tip); - record.entity.polyline.positions.setValue(stemPositions); - return true; -} - function featureLabelFromProperties(props, layerId) { const tags = props.tags || {}; @@ -1336,28 +306,6 @@ export function labelPriorityFromProperties(props, layerId) { return score; } -function propertyObject(entity) { - const source = entity?.properties; - const raw = - typeof source?.getValue === 'function' - ? source.getValue(Cesium.JulianDate.now()) - : source || {}; - return unwrapProperties(raw); -} - -function unwrapProperties(value) { - if (!value || typeof value !== 'object') return value; - if (Array.isArray(value)) return value.map(unwrapProperties); - const out = {}; - for (const [key, entry] of Object.entries(value)) { - out[key] = - entry && typeof entry.getValue === 'function' - ? unwrapProperties(entry.getValue(Cesium.JulianDate.now())) - : unwrapProperties(entry); - } - return out; -} - function cleanLabel(value) { const text = String(value || '').trim(); if (!text || text === 'undefined' || text === 'null') return ''; diff --git a/src/data/localGeojsonLifecycle.test.mjs b/src/data/localGeojsonLifecycle.test.mjs deleted file mode 100644 index 8b5621ec4..000000000 --- a/src/data/localGeojsonLifecycle.test.mjs +++ /dev/null @@ -1,175 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; -import { localGeoJsonServices } from '../app/localGeojsonServices.js'; -import { createLocalGeoJsonLayer as createLayer } from './localGeojsonCore.js'; -import { getContextStore } from './contextStore.js'; - -/** The catalog's services, with the overlay host each test supplies. */ -const createLocalGeoJsonLayer = (options) => - createLayer(options, { - ...localGeoJsonServices, - overlayHost: options.overlayHost, - }); - -const dataset = JSON.stringify({ - type: 'Feature', id: 'dam', properties: { name: 'Test dam' }, - geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] }, -}); -const response = () => ({ ok: true, text: async () => dataset }); -const deferred = () => { - let resolve; - const promise = new Promise(done => { resolve = done; }); - return { promise, resolve }; -}; - -function harness(t) { - const previousWindow = globalThis.window; - globalThis.window = { dispatchEvent() {} }; - t.after(() => { - layer.destroy(viewer); - if (previousWindow === undefined) delete globalThis.window; - else globalThis.window = previousWindow; - }); - const sources = new Set(); - const listeners = new Set(); - const event = { addEventListener(fn) { listeners.add(fn); return () => listeners.delete(fn); } }; - let handlers = 0; - const viewer = { - dataSources: { - async add(source) { sources.add(source); return source; }, - remove(source) { return sources.delete(source); }, - }, - scene: { canvas: {}, preRender: event, requestRender() {} }, - camera: { moveEnd: event }, - }; - const create = () => createLocalGeoJsonLayer({ - id: 'local-dams', name: 'Dams', color: '#0088ff', url: '/dams.geojsonl', - overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, - screenSpaceEventHandlerFactory() { - handlers++; - return { setInputAction() {}, destroy() { handlers--; } }; - }, - }); - const layer = create(); - return { layer, create, viewer, sources, listeners, handlers: () => handlers }; -} - -test('destroy removes context records and permits a fresh replacement', async t => { - const env = harness(t); - t.mock.method(globalThis, 'fetch', async () => response()); - await env.layer.enable(env.viewer); - const oldEntity = [...getContextStore().entities.values()][0].entity; - assert.equal(getContextStore().entities.size, 1); - env.layer.disable(env.viewer); - assert.equal(getContextStore().entities.size, 0, 'disable releases its records with the source'); - assert.equal(env.sources.size, 0, 'disable removes the built source from the scene'); - env.layer.destroy(env.viewer); - assert.equal(getContextStore().entities.size, 0); - assert.equal(env.sources.size, 0); - assert.equal(env.listeners.size, 0); - assert.equal(env.handlers(), 0); - const replacement = env.create(); - await replacement.enable(env.viewer); - assert.equal(getContextStore().entities.size, 1); - assert.notEqual([...getContextStore().entities.values()][0].entity, oldEntity); - replacement.destroy(env.viewer); - await env.layer.enable(env.viewer); - assert.equal(getContextStore().entities.size, 0); -}); - -for (const phase of ['fetch', 'text', 'parse', 'add']) { - test(`destroy during ${phase} leaves no late objects or context records`, async t => { - const env = harness(t); - const entered = deferred(); - const release = deferred(); - const pause = async value => { entered.resolve(); await release.promise; return value; }; - let signal; - t.mock.method(globalThis, 'fetch', async (_url, options) => { - signal = options.signal; - if (phase === 'fetch') return pause(response()); - if (phase === 'text') return { ok: true, text: () => pause(dataset) }; - return response(); - }); - if (phase === 'parse') { - const load = Cesium.GeoJsonDataSource.load; - t.mock.method(Cesium.GeoJsonDataSource, 'load', async (...args) => pause(await load(...args))); - } - if (phase === 'add') { - t.mock.method(env.viewer.dataSources, 'add', async source => { - await pause(); - env.sources.add(source); - return source; - }); - } - const loading = env.layer.enable(env.viewer); - await entered.promise; - env.layer.destroy(env.viewer); - assert.equal(signal.aborted, true); - release.resolve(); - await loading; - assert.equal(env.sources.size, 0); - assert.equal(env.listeners.size, 0); - assert.equal(env.handlers(), 0); - assert.equal(getContextStore().entities.size, 0); - assert.deepEqual(env.layer.getStats(), { count: 0, lastUpdate: null, error: null }); - }); -} - -test('concurrent enable and disable/re-enable share one pending dataset load', async t => { - const env = harness(t); - const release = deferred(); - const fetchMock = t.mock.method(globalThis, 'fetch', async () => { - await release.promise; - return response(); - }); - const first = env.layer.enable(env.viewer); - env.layer.disable(env.viewer); - const second = env.layer.enable(env.viewer); - release.resolve(); - await Promise.all([first, second]); - assert.equal(fetchMock.mock.callCount(), 1); - assert.equal(env.sources.size, 1); - assert.equal(env.handlers(), 1); - assert.equal(env.listeners.size, 2); - assert.equal([...env.sources][0].show, true); -}); - -test('disable during loading releases the completed source; re-enable rebuilds without refetching', async t => { - const env = harness(t); - const release = deferred(); - const fetchMock = t.mock.method(globalThis, 'fetch', async () => { await release.promise; return response(); }); - const loading = env.layer.enable(env.viewer); - env.layer.disable(env.viewer); - release.resolve(); - await loading; - assert.equal(env.sources.size, 0, 'a build that finishes after disable() is released, not parked hidden'); - assert.equal(env.listeners.size, 0); - assert.equal(getContextStore().entities.size, 0); - await env.layer.enable(env.viewer); - assert.equal(env.sources.size, 1); - assert.equal([...env.sources][0].show, true); - assert.equal(fetchMock.mock.callCount(), 1, 'the parsed dataset is cached across the toggle'); -}); - -test('a toggle-off frees the entities and a toggle-on rebuilds them from the cached dataset', async t => { - const env = harness(t); - const fetchMock = t.mock.method(globalThis, 'fetch', async () => response()); - await env.layer.enable(env.viewer); - const first = [...env.sources][0]; - assert.equal(first.entities.values.length, 1); - assert.equal(getContextStore().entities.size, 1); - env.layer.disable(env.viewer); - assert.equal(env.sources.size, 0, 'no data source stays registered while the layer is off'); - assert.equal(getContextStore().entities.size, 0); - assert.equal(env.layer.getLodDiagnostics().total, 0); - assert.equal(env.layer.getStats().count, 1, 'the row keeps reporting the dataset size'); - await env.layer.enable(env.viewer); - const second = [...env.sources][0]; - assert.notEqual(second, first, 're-enable builds a fresh source'); - assert.equal(second.show, true); - assert.equal(second.entities.values.length, 1); - assert.equal(getContextStore().entities.size, 1); - assert.equal(fetchMock.mock.callCount(), 1, 'no second fetch for a bundled dataset'); - assert.equal(env.layer.getLodDiagnostics().total, 1); -}); diff --git a/src/data/localGeojsonLod.js b/src/data/localGeojsonLod.js index 4a2dd92da..3dff026cd 100644 --- a/src/data/localGeojsonLod.js +++ b/src/data/localGeojsonLod.js @@ -3,16 +3,16 @@ * @module localGeojsonLod * * Pure selection policy for the bundled local-infrastructure layers - * (`local-datacenters`, `local-dams`, and any future `createLocalGeoJsonLayer` - * dataset). Cesium-specific projection, occlusion, and stem geometry stay in - * `localGeojsonCore.js`; this module turns the resulting in-view records into a + * (`local-datacenters`, `local-dams`). Projection, occlusion, and stem + * geometry stay in `src/layers/infrastructure/render/display.js`; this module + * turns the resulting in-view records into a * bounded "active" set — the only records that layer should spend per-frame * stem-geometry and ground-sample work on. * * The screen-space label overlay is already decluttered * (LOCAL_OVERLAY_COHORT_LIMIT); this also bounds world-space stems and their * geometry/ground-sampling work. It does not reduce the number of materialized - * entities. Submarine cables use their own renderer and are not changed here. + * records. Submarine cables use their own renderer and are not changed here. * Performance must be measured against the current renderer, not inferred * from the number of hidden stems. * @@ -297,7 +297,7 @@ export function applyInfraEvictionGrace({ * ------------------------------------------------------------------ */ /** - * Probe window for the motion fallback. `localGeojsonCore.js` marks its selection + * Probe window for the motion fallback. The infrastructure display marks its selection * dirty on `moveEnd`, which covers drags, one-shot `setView`, and voice * `flyTo` — but a tracked-entity follow camera, a Cockpit view, a route * flight, or a continuous orbit never emits one. Without a fallback the @@ -377,7 +377,7 @@ export function infraLodMotionEpsilonM( * creeping camera probes on a fixed cadence instead of on every walk. * * Travel arrives SQUARED so the caller can hand over - * `Cesium.Cartesian3.distanceSquared` and spend no sqrt on a per-walk probe, + * `cartesian3.distanceSquared` and spend no sqrt on a per-walk probe, * the same trade `createCableReferenceSweepGate` makes. * * @param {object} [input] diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index b05c76fba..c8a468db1 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -47,6 +47,32 @@ import { } from '../layers/awareness/panel.js'; import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js'; import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs'; +import { registerLayerRenderers } from '../app/layers/renderers.js'; + +// The layer's view of a stub viewer (render/display.js's interface), drawn +// by nothing: these tests drive the layer's logic, not the ring. It reaches +// the layer through the renderer registry, under an engine kind of its own. +const VIEWER_ENGINE = 'awareness-viewer-test'; +registerLayerRenderers(VIEWER_ENGINE, { + 'military-awareness': ({ viewer }) => ({ + container: viewer.container, + canvas: viewer.canvas, + get camera() { + return viewer.camera; + }, + get trackedEntity() { + return viewer.trackedEntity; + }, + cameraHeight: () => viewer.camera?.positionCartographic?.height ?? NaN, + onFrame: (run) => viewer.scene.preRender.addEventListener(run), + onFrameEnd: () => () => {}, + frameContact: (center, radius, duration) => + viewer.camera.flyToBoundingSphere({ center, radius }, { duration }), + createRim: () => ({ move() {}, setAlpha() {}, destroy() {} }), + createPulses: () => null, + }), +}); +const viewerEngine = (viewer) => ({ kind: VIEWER_ENGINE, viewer }); const militaryAwarenessSource = readLayerSource( new URL('./militaryAwareness.js', import.meta.url), @@ -220,7 +246,7 @@ function installAwarenessRuntime({ ...(subscribe ? { subscribe } : {}), }); militaryAwarenessLayer.setParams({ passive: true }); - militaryAwarenessLayer.init(viewer); + militaryAwarenessLayer.init(viewer, { engine: viewerEngine(viewer) }); const panelViews = []; const unsubscribePanel = militaryAwarenessLayer.subscribePanel((view) => panelViews.push(view), @@ -3747,7 +3773,7 @@ test('runtime listeners exist only while the awareness layer is enabled', () => try { militaryAwarenessLayer.setParams({ passive: true }); - militaryAwarenessLayer.init(viewer); + militaryAwarenessLayer.init(viewer, { engine: viewerEngine(viewer) }); assert.equal( [...listeners.values()].reduce((count, set) => count + set.size, 0), 0, diff --git a/src/data/militaryInstallations.test.mjs b/src/data/militaryInstallations.test.mjs index c3f9cdb89..f0301af46 100644 --- a/src/data/militaryInstallations.test.mjs +++ b/src/data/militaryInstallations.test.mjs @@ -24,7 +24,39 @@ import { installRenderGovernor, } from '../renderGovernor.js'; import * as Cesium from 'cesium'; -import { registerEntityContext, selectEntityContext, getSelectedEntityContext, clearSelectedEntityContextForLayer } from './contextStore.js'; +import { getContextStore, registerEntityContext, selectEntityContext, getSelectedEntityContext, clearSelectedEntityContextForLayer } from './contextStore.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; + +const LAYER_ID = 'military-installations'; + +/** A fake engine looking at `box` (degrees). */ +function engineOver(box) { + const engine = createFakeEngine(); + engine.fake.script('viewRectangle', () => box && { + south: Cesium.Math.toRadians(box.south), + west: Cesium.Math.toRadians(box.west), + north: Cesium.Math.toRadians(box.north), + east: Cesium.Math.toRadians(box.east), + }); + return engine; +} + +/** The context handles of the drawn sites, in drawing order. */ +const drawnHandles = () => + [...getContextStore().entities.values()] + .filter((record) => record.layerId === LAYER_ID) + .map((record) => record.entity); + +/** The drawn disc size of a site: 13 px selected, 9 px otherwise. */ +function discSize(engine, id) { + const drawn = (setId) => { + const set = engine.fake.moverSet(setId); + return set.store.pickIds.slice(0, set.store.count).includes(id); + }; + if (drawn(`${LAYER_ID}-selected`)) return 13; + if (drawn(LAYER_ID)) return 9; + return null; +} test('clicking a selected installation again or empty map clears it through refresh', async () => { const run = await runInstallationLoad({ elements: [{ type: 'node', id: 42, @@ -37,7 +69,7 @@ test('clicking a selected installation again or empty map clears it through refr assert.equal(getSelectedEntityContext(), null); await militaryInstallationsLayer.update(); assert.equal(getSelectedEntityContext(), null, 'refresh must not resurrect selection'); - assert.equal(run.entities()[0].point.pixelSize.getValue(), 9); + assert.equal(run.discSize(run.entities()[0].id), 9); } } finally { run.restore(); } }); @@ -55,7 +87,7 @@ test('background installation paints neither reopen Contacts nor resurrect a cle await militaryInstallationsLayer.update(); assert.equal(getSelectedEntityContext(),null); assert.equal(selected(),1,'an external clear must survive the next paint'); - assert.equal(run.entities()[0].point.pixelSize.getValue(),9); + assert.equal(run.discSize(run.entities()[0].id),9); } finally { run.restore(); } }); @@ -87,8 +119,8 @@ test('switching sites keeps the new selection through refresh and disable clears assert.equal(getSelectedEntityContext()?.id, 'osm:node:43'); await militaryInstallationsLayer.update(); assert.equal(getSelectedEntityContext()?.id, 'osm:node:43'); - assert.equal(run.entities().find(e => e.id === 'osm:node:42').point.pixelSize.getValue(), 9); - assert.equal(run.entities().find(e => e.id === 'osm:node:43').point.pixelSize.getValue(), 13); + assert.equal(run.discSize('osm:node:42'), 9); + assert.equal(run.discSize('osm:node:43'), 13); militaryInstallationsLayer.disable(); run.click('osm:node:42'); assert.equal(getSelectedEntityContext(), null, 'disabled layer ignores clicks'); @@ -134,10 +166,6 @@ test('real enabled installation entities carry no native label graphics', async const originalDocument = globalThis.document; const originalWindow = globalThis.window; const originalFetch = globalThis.fetch; - const canvas = { - addEventListener() {}, - removeEventListener() {}, - }; globalThis.document = { addEventListener() {}, removeEventListener() {}, @@ -163,49 +191,22 @@ test('real enabled installation entities carry no native label graphics', async }), }; }; - const moveEndListeners = new Set(); - const dataSources = []; - const viewer = { - camera: { - moveEnd: { - addEventListener(listener) { - moveEndListeners.add(listener); - return () => moveEndListeners.delete(listener); - }, - }, - computeViewRectangle() { - return { - south: Cesium.Math.toRadians(30), - west: Cesium.Math.toRadians(-98), - north: Cesium.Math.toRadians(31), - east: Cesium.Math.toRadians(-97), - }; - }, - }, - scene: { - canvas, - globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - pick() { return null; }, - }, - dataSources: { - add(dataSource) { dataSources.push(dataSource); return dataSource; }, - remove(dataSource) { - const index = dataSources.indexOf(dataSource); - if (index >= 0) dataSources.splice(index, 1); - return index >= 0; - }, - }, - }; + const engine = engineOver({ south: 30, west: -98, north: 31, east: -97 }); try { - militaryInstallationsLayer.init(viewer); + militaryInstallationsLayer.init(null, { engine }); militaryInstallationsLayer.enable(); await militaryInstallationsLayer.update(); - const entities = dataSources[0].entities.values; + const entities = drawnHandles(); assert.ok(entities.length > 0, 'runtime guard requires rendered installation records'); assert.ok(entities.every((entity) => entity.label === undefined)); + assert.ok( + engine.movers.list().every(({ kind }) => kind === 'point'), + 'the titles are the shared overlay, not label primitives', + ); } finally { - militaryInstallationsLayer.destroy(viewer); + militaryInstallationsLayer.destroy(); + engine.destroy(); globalThis.fetch = originalFetch; if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument; @@ -305,68 +306,37 @@ async function runInstallationLoad({ if (!legacyPayload) payload.saturated = exact ? exactSaturated : saturated; return { ok: true, status: 200, json: async () => payload }; }; - const dataSources = []; - const cameraFlights = []; - let picked = null; - let clickAction; + const engine = engineOver(VIEWPORT); + // Enough of a viewer for the real render governor, so one-shot render + // requests are observable. const viewer = { - camera: { - moveEnd: { addEventListener() { return () => {}; } }, - flyToBoundingSphere(sphere, options) { cameraFlights.push({ sphere, options }); }, - computeViewRectangle() { - return { - south: Cesium.Math.toRadians(VIEWPORT.south), - west: Cesium.Math.toRadians(VIEWPORT.west), - north: Cesium.Math.toRadians(VIEWPORT.north), - east: Cesium.Math.toRadians(VIEWPORT.east), - }; - }, - }, scene: { - canvas: { addEventListener() {}, removeEventListener() {} }, - globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - pick() { return picked; }, - // Enough surface for the real render governor to drive this viewer, so - // one-shot render requests are observable. requestRenderMode: false, maximumRenderTimeChange: 0, requestRender() {}, }, - dataSources: { - add(dataSource) { dataSources.push(dataSource); return dataSource; }, - remove(dataSource) { - const index = dataSources.indexOf(dataSource); - if (index >= 0) dataSources.splice(index, 1); - return index >= 0; - }, - }, }; - - const originalSetInputAction = Cesium.ScreenSpaceEventHandler.prototype.setInputAction; - Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (action, type, modifier) { - if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK) clickAction = action; - return originalSetInputAction.call(this, action, type, modifier); - }; - try { militaryInstallationsLayer.init(viewer); } - finally { Cesium.ScreenSpaceEventHandler.prototype.setInputAction = originalSetInputAction; } + militaryInstallationsLayer.init(null, { engine }); installRenderGovernor(viewer); militaryInstallationsLayer.enable(); await militaryInstallationsLayer.update(); return { requests, - cameraFlights, - entities: () => dataSources[0]?.entities?.values || [], + engine, + entities: drawnHandles, + discSize: (id) => discSize(engine, id), contextLabels: () => contextEvents, stats: () => militaryInstallationsLayer.getStats(), click(target) { - const entity = typeof target === 'string' ? dataSources[0].entities.getById(target) : target; - picked = entity ? { id: entity } : undefined; - clickAction({ position: { x: 0, y: 0 } }); + engine.fake.script('pick', target ? { id: target } : undefined); + engine.fake.input('click', { position: { x: 0, y: 0 } }); + engine.fake.unscript('pick'); }, renderRequests: () => getRenderGovernorDiagnostics().recentRequests.map((item) => item.reason), restore() { - militaryInstallationsLayer.destroy(viewer); + militaryInstallationsLayer.destroy(); + engine.destroy(); _resetRenderGovernorForTest(); globalThis.fetch = originalFetch; if (originalDocument === undefined) delete globalThis.document; @@ -603,44 +573,18 @@ test('a floor that lands after the render deadline lifts the dots off the ellips }), }; }; - const dataSources = []; - const viewer = { - camera: { - moveEnd: { addEventListener() { return () => {}; } }, - computeViewRectangle() { - return { - south: Cesium.Math.toRadians(44), - west: Cesium.Math.toRadians(-111), - north: Cesium.Math.toRadians(45), - east: Cesium.Math.toRadians(-110), - }; - }, - }, - scene: { - canvas: { addEventListener() {}, removeEventListener() {} }, - globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - pick() { return null; }, - }, - dataSources: { - add(dataSource) { dataSources.push(dataSource); return dataSource; }, - remove(dataSource) { - const index = dataSources.indexOf(dataSource); - if (index >= 0) dataSources.splice(index, 1); - return index >= 0; - }, - }, - }; + const engine = engineOver({ south: 44, west: -111, north: 45, east: -110 }); const heightOf = (entity) => Cesium.Cartographic.fromCartesian( - entity.position.getValue(Cesium.JulianDate.now()), + entity.gevDisplayPosition(), ).height; try { - militaryInstallationsLayer.init(viewer); + militaryInstallationsLayer.init(null, { engine }); militaryInstallationsLayer.enable(); await militaryInstallationsLayer.update(); - const buried = dataSources[0].entities.values[0]; + const buried = drawnHandles()[0]; assert.ok(buried, 'the cold-floor pass still renders the record'); assert.ok(Math.abs(heightOf(buried)) < 1, 'a cold floor anchors at the ellipsoid, as before'); @@ -650,13 +594,14 @@ test('a floor that lands after the render deadline lifts the dots off the ellips } assert.equal(cachedGroundFloor(lat, lon), 2400, 'the late floor landed in the shared cache'); - const lifted = dataSources[0].entities.values[0]; + const lifted = drawnHandles()[0]; assert.ok( Math.abs(heightOf(lifted) - 2401.5) < 0.5, `re-render must lift the dot onto the resolved floor, got ${heightOf(lifted)}`, ); } finally { - militaryInstallationsLayer.destroy(viewer); + militaryInstallationsLayer.destroy(); + engine.destroy(); _resetFireAnchorsForTest(); setMeshFloorPreferred(true); globalThis.fetch = originalFetch; @@ -681,28 +626,10 @@ test('reports bounded installation requests as loading and clears on settlement' } return installationsResponse; }; - const viewer = { - camera: { - moveEnd: { addEventListener() { return () => {}; } }, - computeViewRectangle() { - return { - south: Cesium.Math.toRadians(30), - west: Cesium.Math.toRadians(-98), - north: Cesium.Math.toRadians(31), - east: Cesium.Math.toRadians(-97), - }; - }, - }, - scene: { - canvas: { addEventListener() {}, removeEventListener() {} }, - globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - pick() { return null; }, - }, - dataSources: { add(value) { return value; }, remove() { return true; } }, - }; + const engine = engineOver({ south: 30, west: -98, north: 31, east: -97 }); try { - militaryInstallationsLayer.init(viewer); + militaryInstallationsLayer.init(null, { engine }); militaryInstallationsLayer.enable(); const update = militaryInstallationsLayer.update(); assert.equal(militaryInstallationsLayer.getStats().loading, true); @@ -718,7 +645,8 @@ test('reports bounded installation requests as loading and clears on settlement' await update; assert.equal(militaryInstallationsLayer.getStats().loading, false); } finally { - militaryInstallationsLayer.destroy(viewer); + militaryInstallationsLayer.destroy(); + engine.destroy(); globalThis.fetch = originalFetch; if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument; @@ -745,28 +673,16 @@ test('zoom-out aborts an active installation request and returns non-loading gui }, { once: true }); }); }; - const viewer = { - camera: { - moveEnd: { addEventListener() { return () => {}; } }, - computeViewRectangle() { - return globalView ? null : { - south: Cesium.Math.toRadians(30), - west: Cesium.Math.toRadians(-98), - north: Cesium.Math.toRadians(31), - east: Cesium.Math.toRadians(-97), - }; - }, - }, - scene: { - canvas: { addEventListener() {}, removeEventListener() {} }, - globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - pick() { return null; }, - }, - dataSources: { add(value) { return value; }, remove() { return true; } }, - }; + const engine = engineOver({ south: 30, west: -98, north: 31, east: -97 }); + engine.fake.script('viewRectangle', () => globalView ? null : { + south: Cesium.Math.toRadians(30), + west: Cesium.Math.toRadians(-98), + north: Cesium.Math.toRadians(31), + east: Cesium.Math.toRadians(-97), + }); try { - militaryInstallationsLayer.init(viewer); + militaryInstallationsLayer.init(null, { engine }); militaryInstallationsLayer.enable(); const pending = militaryInstallationsLayer.update(); assert.equal(militaryInstallationsLayer.getStats().loading, true); @@ -779,7 +695,8 @@ test('zoom-out aborts an active installation request and returns non-loading gui assert.equal(militaryInstallationsLayer.getStats().error, null, 'guidance is not a fault'); assert.match(militaryInstallationsLayer.getStats().statusMessage, /zoom in/i); } finally { - militaryInstallationsLayer.destroy(viewer); + militaryInstallationsLayer.destroy(); + engine.destroy(); globalThis.fetch = originalFetch; if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument; @@ -794,7 +711,7 @@ test('zoom-out aborts an active installation request and returns non-loading gui // sat healthy — observed in the field as a layer stuck reporting unavailable // while its own endpoint served hundreds of features. The backoff progression // is a pure exported helper so it pins without booting the layer (the full -// layer needs a Cesium viewer and interaction handlers); the wiring is pinned +// layer needs a globe engine and interaction handlers); the wiring is pinned // by source probes against the shipped file, the same technique the HUD datum // tests use where a full boot is impractical. import { installationRetryDelayMs } from './militaryInstallations.js'; diff --git a/src/data/sharedOsmDisplays.test.mjs b/src/data/sharedOsmDisplays.test.mjs index 45c55071e..cce2b2891 100644 --- a/src/data/sharedOsmDisplays.test.mjs +++ b/src/data/sharedOsmDisplays.test.mjs @@ -39,14 +39,18 @@ function assertCredit(v) { assert.ok(v.credits.some((credit) => credit.showOnScreen && credit.html.includes('>© OpenStreetMap<'))); } for (const index of [0, 1]) test(`${index ? 'dams' : 'datacenters'} introduce the shared OSM credit when data displays`, async (t) => { - const v = viewer(t); + const before = globalThis.window; + globalThis.window = { dispatchEvent: noop }; + t.after(() => { if (before === undefined) delete globalThis.window; else globalThis.window = before; }); + const engine = createFakeEngine(); t.mock.method(globalThis, 'fetch', async () => ({ ok: true, text: async () => JSON.stringify({ type: 'Feature', id: 'test', properties: { name: 'Site' }, geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] } }) })); - const layer = createInfrastructureLayers({ overlayHost, registerEntityContext: noop, selectEntityContext: noop, clearSelectedEntityContextForLayer: noop, removeEntityContextsForLayer: noop, governorRequestRender: noop, showOsmCredit, hideOsmCredit })[index]; - await layer.enable(v); + const layer = createInfrastructureLayers({ overlayHost, registerEntityContext: noop, selectEntityContext: noop, clearSelectedEntityContextForLayer: noop, removeEntityContextsForLayer: noop, showOsmCredit, hideOsmCredit })[index]; + layer.init(null, { engine }); + await layer.enable(); assert.equal(layer.getStats().count, 1); - assertCredit(v); - layer.destroy(v); - assert.equal(v.credits.filter(c => c.showOnScreen).length, 0); + assert.ok(engine.fake.credits.some((credit) => credit.onScreen && credit.html.includes('>© OpenStreetMap<'))); + layer.destroy(); + assert.equal(engine.fake.credits.filter((credit) => credit.onScreen).length, 0); }); test('Nepal locator introduces the shared OSM credit', async (t) => { diff --git a/src/data/trackedReadout.test.mjs b/src/data/trackedReadout.test.mjs index b7583e363..2c8790651 100644 --- a/src/data/trackedReadout.test.mjs +++ b/src/data/trackedReadout.test.mjs @@ -284,11 +284,11 @@ test('tracking layers write gevLabelModel and expose only their cached display p assert.ok(source.includes('.gevDisplayPosition ='), `${name} exposes a display-position cache`); } const installations = await readFile( - new URL('../layers/installations/render/cesium/rendering.js', import.meta.url), + new URL('../layers/installations/render/display.js', import.meta.url), 'utf8', ); - assert.ok(installations.includes('entity.gevLabelModel =')); - assert.ok(installations.includes('entity.gevDisplayPosition = () => displayPosition')); + assert.ok(installations.includes(' gevLabelModel: {')); + assert.ok(installations.includes('handle.gevDisplayPosition = () => handle.position')); assert.match(sources['flights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/); assert.match(sources['militaryFlights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/); assert.ok(sources['satellites.js'].includes('gevDisplayPosition = _trackedDisplayCached')); diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index fa13a0e83..f3048ee4f 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -1,4 +1,3 @@ -import { createAwarenessCesiumRenderer } from './render/cesium.js'; import { cameraPoseSignature } from '../../data/iconOrientation.js'; import { AWARENESS_RANGE_PULSE } from './policy.js'; @@ -281,12 +280,11 @@ export function createLifecycle({ if (viewer || engine) this.attachRenderer(viewer, { engine }); }, - attachRenderer(viewer, { engine = null } = {}) { + attachRenderer(_scene, { engine = null } = {}) { detachRuntimeListeners(); layerState.display?.destroy?.(); - layerState.display = viewer - ? createAwarenessCesiumRenderer(viewer) - : (services.renderer?.(engine) ?? null); + // The engine's display from the registry (render/display.js). + layerState.display = services.renderer?.(engine) ?? null; if (layerState.enabled) { attachRuntimeListeners(); if (layerState.subject) diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index a52caaf19..a51a5720a 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -1,12 +1,14 @@ // src/layers/awareness/rangeRing.test.mjs — the 250 km Global Context range -// ring is a haloed polyline that stays legible on light and dark ground, -// sits where the old ellipse outline did, and follows the subject. +// ring is a core line over a halo line that stays legible on light and dark +// ground, sits where the old ellipse outline did, and follows the subject. // Dim sonar pulses grow slowly from the subject to the ring, each with an // inward-fading gradient behind it, drawn on one unpickable disc; the ring // dims and flashes as each one arrives and the overlay's echo field follows // the subject. The pulses run for a bounded time after a selection or camera // move, then rest with the ring steady. Under reduced motion the ring simply -// stays on screen. +// stays on screen. The display runs on the Cesium engine over a stub scene +// (src/testSupport/cesiumMoversEngine.mjs): its ring lines and the Cesium +// pulse disc are real Cesium primitives that draw nothing. import '../../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -25,14 +27,19 @@ import { awarenessPulseState, awarenessRangeRingPositions, awarenessRingFlash, - awarenessRingGeometryOptions, createRendering, wakeAwarenessPulses, } from './rendering.js'; import { getAwarenessEchoField } from '../../awarenessEcho.js'; import { createLifecycle } from './lifecycle.js'; -import { createAwarenessCesiumRenderer } from './render/cesium.js'; import { createState } from './state.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { createCesiumMoversEngine } from '../../testSupport/cesiumMoversEngine.mjs'; +import { + createAwarenessDisplay, + RING_HALO_ID, + RING_ID, +} from './render/display.js'; /** WCAG relative luminance of a Cesium colour, ignoring alpha. */ function luminance(color) { @@ -46,12 +53,15 @@ function luminance(color) { } /** - * The rendering part over a bare entity collection, counting render asks and - * by reason. `reducedMotion` stubs the media query for the length of the + * The rendering part over the display on the Cesium engine (or `engine`), + * counting render asks by reason. `reducedMotion` stubs the media query for the length of the * test; `motion.set(on)` flips it later and tells its listeners, as the * browser does. */ -function ringRendering(t, { reducedMotion = false, legacyQuery = false } = {}) { +function ringRendering( + t, + { reducedMotion = false, legacyQuery = false, engine: given = null } = {}, +) { const previous = globalThis.matchMedia; const listeners = new Set(); // `legacyQuery` is a MediaQueryList as Safari before 14 shipped it, with @@ -94,13 +104,11 @@ function ringRendering(t, { reducedMotion = false, legacyQuery = false } = {}) { listeners: () => listeners.size, }; const state = createState({ services: {} }); - const entities = new Cesium.EntityCollection(); - const scene = { - primitives: new Cesium.PrimitiveCollection(), - preRender: new Cesium.Event(), - postRender: new Cesium.Event(), - }; - state.display = createAwarenessCesiumRenderer({ entities, scene }); + const cesium = given ? null : createCesiumMoversEngine(); + const engine = given ?? cesium.engine; + const viewer = cesium?.viewer ?? null; + const scene = viewer?.scene ?? null; + state.display = createAwarenessDisplay(engine); const reasons = []; const rendering = createRendering({ state, @@ -115,11 +123,18 @@ function ringRendering(t, { reducedMotion = false, legacyQuery = false } = {}) { parts: {}, }); // A shown ring keeps a pulse timer; never leave one running past a test. - t.after(() => rendering.clearVisual()); + t.after(() => { + rendering.clearVisual(); + state.display?.destroy(); + engine.destroy(); + }); return { state, - entities, + engine, + viewer, + entities: viewer?.entities, scene, + primitive: cesium?.primitive, rendering, motion, reasons, @@ -144,13 +159,17 @@ function frameLoop(t, ring, { date = false } = {}) { now: clock, }); let drawn = 0; + let lastFrame = clock; const draw = () => { drawn = ring.reasons.length; + lastFrame = clock; ring.scene.preRender.raiseEvent(ring.scene); ring.scene.postRender.raiseEvent(ring.scene); }; return { now: () => clock, + /** When the last frame was drawn. */ + lastFrame: () => lastFrame, draw, advance(ms) { for (let step = 0; step < ms; step++) { @@ -162,16 +181,29 @@ function frameLoop(t, ring, { date = false } = {}) { }; } -/** The ring and the pulse disc among scene primitives. */ -function pulsePrimitives(scene) { +/** + * The ring's core and halo lines and the pulse disc among the scene's + * primitives; `drawn` counts all three. + */ +function pulsePrimitives(ring) { + const { scene } = ring; const all = Array.from({ length: scene.primitives.length }, (_, i) => scene.primitives.get(i), ); - const ring = all.find( - (p) => p.appearance instanceof Cesium.PolylineMaterialAppearance, + const discs = all.filter( + (p) => + p instanceof Cesium.Primitive && + p.appearance instanceof Cesium.MaterialAppearance, ); - const discs = all.filter((p) => p instanceof Cesium.Primitive && p !== ring); - return { ring, disc: discs[0], count: discs.length }; + const core = ring.primitive(RING_ID); + const halo = ring.primitive(RING_HALO_ID); + return { + ring: core, + halo, + disc: discs[0], + count: discs.length, + drawn: [core, halo, ...discs].filter(Boolean).length, + }; } /** Where a disc built around `built` sits after its model matrix. */ @@ -183,10 +215,25 @@ function discCenter(disc, built) { ); } -/** The ring's material uniforms as the next drawn frame sets them. */ -function ringColors(scene) { - scene.preRender.raiseEvent(scene); - return pulsePrimitives(scene).ring.appearance.material.uniforms; +/** + * The ring's colours, core and halo, as the next drawn frame sets them + * (`{ frame: false }`: as the last one did). + */ +function ringColors(ring, { frame = true } = {}) { + if (frame) ring.scene.preRender.raiseEvent(ring.scene); + const { ring: core, halo } = pulsePrimitives(ring); + return { + color: core.appearance.material.uniforms.color, + outlineColor: halo.appearance.material.uniforms.color, + }; +} + +/** Puts the Cesium camera 800 km over (lon, 20°), looking down at a slant. */ +function setCamera(ring, lon) { + ring.viewer.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(lon, 20, 800_000), + orientation: { heading: 0, pitch: -1, roll: 0 }, + }); } const flight = (lon) => ({ @@ -288,79 +335,109 @@ test('the ring core and halo contrast on light and dark ground', () => { assert.ok(width - 2 * outlineWidth >= 2); }); -test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) => { - const ring = ringRendering(t); - const { entities, scene, state, rendering } = ring; - frameLoop(t, ring); // freeze the clock: the pulses have not faded in yet - rendering.renderVisual(flight(3)); - // A polyline primitive, never an entity or an ellipse outline. - assert.equal(entities.values.length, 0); - const ringPrimitive = pulsePrimitives(scene).ring; - assert.ok(ringPrimitive); - const geometry = ringPrimitive.geometryInstances.geometry; - assert.ok(geometry instanceof Cesium.PolylineGeometry); - // 4 px wide, straight chords between surface-following positions, as the - // entity ring was drawn. - const options = awarenessRingGeometryOptions(state.visual.ringPositions); - assert.equal(options.width, 4); - assert.equal(options.width, AWARENESS_RANGE_RING_STYLE.width); - assert.equal(options.arcType, Cesium.ArcType.NONE); - assert.equal(options.positions, state.visual.ringPositions); - assert.equal( - options.vertexFormat, - Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, - ); - const { material } = ringPrimitive.appearance; - assert.equal(material.type, 'PolylineOutline'); - // The ring's hue is fixed; its opacity flashes with arriving pulses, and - // at the moment it appears, before the pulses fade in, it is at full. - const core = Cesium.Color.fromCssColorString( - AWARENESS_RANGE_RING_STYLE.color, - ); - const { color, outlineWidth } = ringColors(scene); - assert.equal(color.red, core.red); - assert.equal(color.blue, core.blue); - assert.ok(Math.abs(color.alpha - core.alpha) < 1e-9, `${color.alpha}`); - assert.equal(outlineWidth, AWARENESS_RANGE_RING_STYLE.outlineWidth); - const ground = Cesium.Cartesian3.fromDegrees(3, 27, 0); - for (const distance of surfaceDistances(ground, state.visual.ringPositions)) - assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100); +test('renderVisual draws the ring as a core line over a halo line, not an ellipse outline', (t) => { + for (const onCesium of [true, false]) { + const engine = onCesium ? null : createFakeEngine(); + const ring = ringRendering(t, { engine }); + const { state, rendering } = ring; + // Freeze the clock: the pulses have not faded in yet. + if (onCesium) frameLoop(t, ring); + rendering.renderVisual(flight(3)); + const ground = Cesium.Cartesian3.fromDegrees(3, 27, 0); + const core = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ); + const halo = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.outlineColor, + ); + if (onCesium) { + // Line primitives, never an entity or an ellipse outline. + assert.equal(ring.entities.values.length, 0); + const { ring: coreLine, halo: haloLine } = pulsePrimitives(ring); + assert.ok( + coreLine.geometryInstances.geometry instanceof Cesium.PolylineGeometry, + ); + assert.ok( + haloLine.geometryInstances.geometry instanceof Cesium.PolylineGeometry, + ); + // The ring's hue is fixed; its opacity flashes with arriving pulses, + // and at the moment it appears, before the pulses fade in, it is at + // full. + const { color, outlineColor } = ringColors(ring); + assert.equal(color.red, core.red); + assert.equal(color.blue, core.blue); + assert.ok(Math.abs(color.alpha - core.alpha) < 1e-9, `${color.alpha}`); + assert.ok(Math.abs(outlineColor.alpha - halo.alpha) < 1e-9); + continue; + } + // 4 px wide with a 1 px halo, as Cesium's outline material drew the ring + // (its core is the width less the outline), straight chords between + // surface-following positions. + const coreLine = engine.fake.line(RING_ID); + const haloLine = engine.fake.line(RING_HALO_ID); + assert.equal(haloLine.options.widthPx, AWARENESS_RANGE_RING_STYLE.width); + assert.equal( + coreLine.options.widthPx, + AWARENESS_RANGE_RING_STYLE.width - + AWARENESS_RANGE_RING_STYLE.outlineWidth, + ); + assert.equal(coreLine.options.arc, 'none'); + assert.ok( + coreLine.options.order > haloLine.options.order, + 'the core over the halo', + ); + assert.equal(coreLine.options.pickId, undefined, 'never a click target'); + assert.ok(Math.abs(coreLine.color[3] - core.alpha) < 1e-9); + assert.ok(Math.abs(haloLine.color[3] - halo.alpha) < 1e-9); + const flat = coreLine.positions; + const positions = Array.from( + { length: flat.length / 3 }, + (_, i) => + new Cesium.Cartesian3(flat[i * 3], flat[i * 3 + 1], flat[i * 3 + 2]), + ); + assert.equal(positions.length, AWARENESS_RANGE_RING_STYLE.segments + 1); + assert.deepEqual(haloLine.positions, flat); + for (const distance of surfaceDistances(ground, positions)) + assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100); + rendering.clearVisual(); + assert.equal(engine.fake.line(RING_ID), undefined); + assert.ok(state); + } }); test('the ring and pulses follow the subject past 250 m and ignore smaller drift', (t) => { - const { scene, state, rendering, renders } = ringRendering(t); + const ring = ringRendering(t); + const { state, rendering, renders } = ring; const subject = flight; rendering.renderVisual(subject(3)); - const ringBefore = pulsePrimitives(scene).ring; - const first = state.visual.ringPositions; - const discBefore = pulsePrimitives(scene).disc; + const ringBefore = pulsePrimitives(ring).ring; + const discBefore = pulsePrimitives(ring).disc; const rendersAfterAdd = renders(); // ~100 m east: under the threshold, nothing is rebuilt or rendered. rendering.renderVisual(subject(3.001)); - assert.equal(pulsePrimitives(scene).ring, ringBefore); - assert.equal(state.visual.ringPositions, first); + assert.equal(pulsePrimitives(ring).ring, ringBefore); assert.equal(renders(), rendersAfterAdd); - // ~1 km east: the ring is rebuilt around the new centre, keeping its - // material, so a flash carries on. + // ~1 km east: the ring is rebuilt around the new centre. rendering.renderVisual(subject(3.01)); - const ringAfter = pulsePrimitives(scene).ring; + const ringAfter = pulsePrimitives(ring).ring; assert.notEqual(ringAfter, ringBefore); assert.ok(ringBefore.isDestroyed()); - assert.equal(ringAfter.appearance.material, ringBefore.appearance.material); assert.equal(ringAfter.allowPicking, false); - const moved = state.visual.ringPositions; - assert.notEqual(moved, first); - for (const distance of surfaceDistances( - Cesium.Cartesian3.fromDegrees(3.01, 27, 0), - moved, - )) - assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100); + assert.equal(pulsePrimitives(ring).halo.allowPicking, false); + assert.ok( + Cesium.Cartesian3.equalsEpsilon( + state.visual.center, + Cesium.Cartesian3.fromDegrees(3.01, 27, 0), + 0, + 1e-3, + ), + ); assert.equal(renders(), rendersAfterAdd + 1); // The pulses are drawn on a disc that follows to the NEW centre by its // model matrix, so they stay concentric with the ring without a rebuild. - assert.equal(pulsePrimitives(scene).disc, discBefore); + assert.equal(pulsePrimitives(ring).disc, discBefore); assert.ok( Cesium.Cartesian3.distance( discCenter(discBefore, Cesium.Cartesian3.fromDegrees(3, 27, 0)), @@ -369,7 +446,7 @@ test('the ring and pulses follow the subject past 250 m and ignore smaller drift ); rendering.clearVisual(); - assert.equal(scene.primitives.length, 0); + assert.equal(pulsePrimitives(ring).drawn, 0); }); test('a pulse grows slowly from the subject and arrives dim at the ring', () => { @@ -441,7 +518,7 @@ test('pulses ask for a frame 80 ms after each drawn frame instead of holding con assert.ok(Math.abs(pulseFrames() - 10) <= 1, `${pulseFrames()} frames`); const frames = pulseFrames(); rendering.clearVisual(); - assert.equal(ring.scene.primitives.length, 0); + assert.equal(pulsePrimitives(ring).drawn, 0); assert.equal(ring.scene.postRender.numberOfListeners, 0); loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10); assert.equal(pulseFrames(), frames, 'no frames once the ring is gone'); @@ -449,7 +526,7 @@ test('pulses ask for a frame 80 ms after each drawn frame instead of holding con test('a parked view stops asking for frames once the pulses rest', (t) => { const ring = ringRendering(t); - const { scene, rendering, pulseFrames } = ring; + const { rendering, pulseFrames } = ring; const loop = frameLoop(t, ring); const { activeMs, count, periodMs } = AWARENESS_RANGE_PULSE; // Long enough for three ring flashes, then quiet, and short: a burst is @@ -472,8 +549,8 @@ test('a parked view stops asking for frames once the pulses rest', (t) => { const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - assert.ok(Math.abs(ringColors(scene).color.alpha - core.alpha) < 1e-9); - assert.equal(pulsePrimitives(scene).disc.show, false); + assert.ok(Math.abs(ringColors(ring).color.alpha - core.alpha) < 1e-9); + assert.equal(pulsePrimitives(ring).disc.show, false); assert.equal(getAwarenessEchoField().level(loop.now()), 0); }); @@ -508,15 +585,9 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera const loop = frameLoop(t, ring, { date: true }); const { activeMs, inputWakeMs } = AWARENESS_RANGE_PULSE; state.enabled = true; - state.display.viewer.canvas = new EventTarget(); const touch = () => - state.display.viewer.canvas.dispatchEvent(new Event('pointerdown')); - state.display.viewer.camera = { - positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000), - heading: 0, - pitch: -1, - roll: 0, - }; + state.display.canvas.dispatchEvent(new Event('pointerdown')); + setCamera(ring, 3); const lifecycle = createLifecycle({ state, services: { @@ -542,11 +613,7 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera // The user drags the camera 50 km: the next frame wakes them for another // run. touch(); - state.display.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( - 3.5, - 20, - 800_000, - ); + setCamera(ring, 3.5); loop.draw(); loop.advance(activeMs + 500); assert.ok( @@ -559,11 +626,7 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera // tracking follow, the cockpit view, an orbit or a fly-to all move it on // every frame. const follow = (lon) => { - state.display.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( - lon, - 20, - 800_000, - ); + setCamera(ring, lon); loop.draw(); loop.advance(500); }; @@ -573,7 +636,7 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera // without a button pressed is not handling the camera. touch(); loop.advance(inputWakeMs + 100); - state.display.viewer.canvas.dispatchEvent( + state.display.canvas.dispatchEvent( Object.assign(new Event('pointermove'), { buttons: 0 }), ); follow(5.7); @@ -588,8 +651,7 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera loop.advance(activeMs); return pulseFrames() > before; }; - const onCanvas = (event) => () => - state.display.viewer.canvas.dispatchEvent(event); + const onCanvas = (event) => () => state.display.canvas.dispatchEvent(event); assert.equal(panWith(onCanvas(new Event('wheel')), 5.8), true, 'wheel'); assert.equal(panWith(onCanvas(new Event('touchstart')), 5.9), true, 'touch'); assert.equal( @@ -620,16 +682,11 @@ function inputLifecycle(t, ring) { const { state, rendering } = ring; const loop = frameLoop(t, ring, { date: true }); state.enabled = true; - state.display.viewer.canvas = new EventTarget(); - state.display.viewer.scene.screenSpaceCameraController = { - enableInputs: true, - }; - state.display.viewer.camera = { - positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000), - heading: 0, - pitch: -1, - roll: 0, - }; + // The contract cannot say whether the camera controller takes input, so + // the display does not either; the lifecycle reads it when a display does. + const controller = { enableInputs: true }; + state.display.cameraInputsEnabled = () => controller.enableInputs; + setCamera(ring, 3); const lifecycle = createLifecycle({ state, services: { @@ -651,18 +708,12 @@ function inputLifecycle(t, ring) { return { loop, lifecycle, - controller: state.display.viewer.scene.screenSpaceCameraController, + controller, dispatch: (type, init = {}) => - state.display.viewer.canvas.dispatchEvent( - Object.assign(new Event(type), init), - ), + state.display.canvas.dispatchEvent(Object.assign(new Event(type), init)), pan(stepDeg = 0.1) { lon += stepDeg; - state.display.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( - lon, - 20, - 800_000, - ); + setCamera(ring, lon); loop.draw(); }, /** Whether the pulses drew frames during the next `ms`. */ @@ -834,12 +885,7 @@ test('disabling the layer tears the pulses down completely', (t) => { const ring = ringRendering(t); const { state, scene, rendering, motion, pulseFrames } = ring; const loop = frameLoop(t, ring); - state.display.viewer.camera = { - positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000), - heading: 0, - pitch: -1, - roll: 0, - }; + setCamera(ring, 3); const lifecycle = createLifecycle({ state, services: { @@ -869,9 +915,9 @@ test('disabling the layer tears the pulses down completely', (t) => { rendering.renderVisual(state.subject); loop.advance(1000); assert.ok(pulseFrames() > 0); - assert.equal(scene.primitives.length, 2); + assert.equal(pulsePrimitives(ring).drawn, 3); lifecycle.methods.disable(); - assert.equal(scene.primitives.length, 0, 'ring and disc removed'); + assert.equal(pulsePrimitives(ring).drawn, 0, 'ring and disc removed'); assert.equal(scene.preRender.numberOfListeners, 0); assert.equal(scene.postRender.numberOfListeners, 0); assert.equal(motion.listeners(), 0, 'no media-query listener'); @@ -924,22 +970,17 @@ test('the pulse level fades in, holds, fades out and keeps its level when woken' assert.equal(awarenessPulseLevel(at + activeMs, wake), 0); }); -test('a scene without primitives or preRender gets the static ring only', (t) => { - const { state, entities, rendering, pulseFrames } = ringRendering(t); - for (const scene of [ - {}, - { primitives: { add() {} } }, - { primitives: { add() {} }, preRender: new Cesium.Event() }, - undefined, - ]) { - state.display = createAwarenessCesiumRenderer({ entities, scene }); - rendering.clearVisual(); - rendering.renderVisual(flight(3)); - // No usable primitive collection: the ring falls back to an entity. - assert.equal(entities.values.length, 1); - assert.equal(entities.values[0].polyline.positions.isConstant, true); - assert.equal(entities.values[0].polyline.material.color.isConstant, true); - } +test('an engine without a pulse backend gets the static ring only', (t) => { + const engine = createFakeEngine(); + const preRender = engine.frames.preRender.numberOfListeners; + const ring = ringRendering(t, { engine }); + const { rendering, pulseFrames } = ring; + rendering.renderVisual(flight(3)); + assert.ok(engine.fake.line(RING_ID)); + assert.ok(engine.fake.line(RING_HALO_ID)); + assert.equal(ring.state.visual.disc, null); + assert.equal(engine.frames.preRender.numberOfListeners, preRender); + assert.equal(getAwarenessEchoField(), null); rendering.clearVisual(); assert.equal(pulseFrames(), 0); }); @@ -950,8 +991,12 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); assert.ok(AWARENESS_RANGE_PULSE.count <= AWARENESS_PULSE_GRADIENT_MAX); - assert.equal(scene.primitives.length, 2, 'the ring and the pulse disc'); - const { disc, count } = pulsePrimitives(scene); + assert.equal( + pulsePrimitives(ring).drawn, + 3, + 'the ring lines and the pulse disc', + ); + const { disc, count } = pulsePrimitives(ring); assert.equal(count, 1, 'the gradient disc'); assert.ok(disc); // Never a click or tap target, and see-through. @@ -1006,7 +1051,7 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( const built = Cesium.Cartesian3.fromDegrees(3, 27, 0); for (const lon of [3.001, 3.01, 3.1, 3.19]) { rendering.renderVisual(flight(lon)); - assert.equal(pulsePrimitives(scene).disc, disc, `${lon}`); + assert.equal(pulsePrimitives(ring).disc, disc, `${lon}`); assert.ok(!disc.isDestroyed()); // Concentric with the ring wherever the subject went, within a metre // across the ground; the ellipsoid curves away from the rotation's @@ -1018,15 +1063,15 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( assert.ok(Math.abs(at.height) < 50, `${at.height} m off the ground`); } rendering.renderVisual(flight(3.25)); // ~25 km from where it was built - assert.equal(pulsePrimitives(scene).count, 1); - const moved = pulsePrimitives(scene).disc; + assert.equal(pulsePrimitives(ring).count, 1); + const moved = pulsePrimitives(ring).disc; assert.notEqual(moved, disc); assert.ok(disc.isDestroyed()); assert.equal(moved.appearance.material, material); assert.equal(moved.allowPicking, false); rendering.clearVisual(); - assert.equal(scene.primitives.length, 0); + assert.equal(pulsePrimitives(ring).drawn, 0); assert.equal(scene.preRender.numberOfListeners, 0); }); @@ -1039,19 +1084,19 @@ test("switching subjects keeps one pulse material in Cesium's global cache", (t) const cached = () => Object.keys(Cesium.Material._materialCache._materials).length; rendering.renderVisual({ ...flight(3), id: 'subject-0' }); - const first = pulsePrimitives(scene).disc.appearance.material; + const first = pulsePrimitives(ring).disc.appearance.material; const size = cached(); let last = first; for (let index = 1; index <= 6; index++) { rendering.renderVisual({ ...flight(3 + index), id: `subject-${index}` }); - last = pulsePrimitives(scene).disc.appearance.material; + last = pulsePrimitives(ring).disc.appearance.material; } assert.equal(cached(), size, 'subject switches add no cache entries'); rendering.clearVisual(); rendering.renderVisual(flight(3)); assert.equal(cached(), size, 'nor does a reselect'); // A material built from the cached template still owns its uniform values. - const again = pulsePrimitives(scene).disc.appearance.material; + const again = pulsePrimitives(ring).disc.appearance.material; assert.notEqual(again, last); assert.notEqual(again.uniforms.radii, last.uniforms.radii); assert.notEqual(again.uniforms.alphas, first.uniforms.alphas); @@ -1066,35 +1111,37 @@ test('a resting pulse disc is not rebuilt as the subject moves, and catches up o const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); loop.advance(AWARENESS_RANGE_PULSE.activeMs + 500); - const resting = pulsePrimitives(scene).disc; + const resting = pulsePrimitives(ring).disc; const restingMatrix = Cesium.Matrix4.clone(resting.modelMatrix); // A flying subject keeps moving while the pulses rest: the hidden disc is // neither moved nor rebuilt, even well past the rebuild distance. for (let lon = 3.01; lon < 3.4; lon += 0.01) rendering.renderVisual(flight(lon)); - assert.equal(pulsePrimitives(scene).disc, resting); + assert.equal(pulsePrimitives(ring).disc, resting); assert.ok(!resting.isDestroyed()); assert.ok(Cesium.Matrix4.equals(resting.modelMatrix, restingMatrix)); // Woken, the disc catches up at once: rebuilt around the subject. rendering.wakePulses(); - const woken = pulsePrimitives(scene).disc; + const woken = pulsePrimitives(ring).disc; assert.notEqual(woken, resting); assert.ok(resting.isDestroyed()); - assert.equal(pulsePrimitives(scene).count, 1); + assert.equal(pulsePrimitives(ring).count, 1); // Woken again without moving: the disc stays. rendering.wakePulses(); - assert.equal(pulsePrimitives(scene).disc, woken); + assert.equal(pulsePrimitives(ring).disc, woken); }); test('the ring and pulses never catch a click: no entities, every primitive unpickable', (t) => { for (const reducedMotion of [false, true]) { - const { entities, scene, rendering } = ringRendering(t, { reducedMotion }); + const ring = ringRendering(t, { reducedMotion }); + const { entities, rendering } = ring; rendering.renderVisual(flight(3)); // Cesium always picks entities, so the awareness visual adds none. assert.equal(entities.values.length, 0); - assert.ok(scene.primitives.length >= 1); - for (let i = 0; i < scene.primitives.length; i++) - assert.equal(scene.primitives.get(i).allowPicking, false); + const { ring: core, halo, disc } = pulsePrimitives(ring); + for (const primitive of [core, halo, disc].filter(Boolean)) + assert.equal(primitive.allowPicking, false); + assert.equal(Boolean(disc), !reducedMotion); rendering.clearVisual(); } }); @@ -1106,11 +1153,11 @@ test('a camera below the rim height hides the pulse disc instead of sweeping the rendering.renderVisual(flight(3)); loop.advance(AWARENESS_RANGE_PULSE.settleMs + 500); const cameraAt = (height) => { - scene.camera = { - positionCartographic: new Cesium.Cartographic(0, 0, height), - }; + ring.viewer.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(0, 0, height), + }); scene.preRender.raiseEvent(scene); - return pulsePrimitives(scene).disc.show; + return pulsePrimitives(ring).disc.show; }; assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M + 500), true); assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M - 500), false, 'cockpit view'); @@ -1119,7 +1166,7 @@ test('a camera below the rim height hides the pulse disc instead of sweeping the const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - assert.ok(Math.abs(ringColors(scene).color.alpha - core.alpha) < 1e-9); + assert.ok(Math.abs(ringColors(ring).color.alpha - core.alpha) < 1e-9); assert.equal(getAwarenessEchoField().level(loop.now()), 0); const frames = ring.pulseFrames(); loop.draw(); @@ -1130,14 +1177,15 @@ test('a camera below the rim height hides the pulse disc instead of sweeping the test('reduced motion keeps the static ring and drops the pulses', (t) => { t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); - const { scene, rendering, pulseFrames } = ringRendering(t, { + const ring = ringRendering(t, { reducedMotion: true, }); + const { scene, rendering, pulseFrames } = ring; rendering.renderVisual(flight(3)); - assert.ok(pulsePrimitives(scene).ring); + assert.ok(pulsePrimitives(ring).ring); t.mock.timers.tick(1000); assert.equal(pulseFrames(), 0); - assert.equal(pulsePrimitives(scene).count, 0, 'no pulse disc'); + assert.equal(pulsePrimitives(ring).count, 0, 'no pulse disc'); assert.equal(scene.preRender.numberOfListeners, 0); assert.equal(scene.postRender.numberOfListeners, 0); }); @@ -1148,12 +1196,12 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t const loop = frameLoop(t, ring); state.subject = flight(3); rendering.renderVisual(state.subject); - assert.equal(pulsePrimitives(scene).count, 1); + assert.equal(pulsePrimitives(ring).count, 1); loop.advance(1000); // The OS setting changes while the same aircraft stays tracked. motion.set(true); - assert.ok(pulsePrimitives(scene).ring); - assert.equal(pulsePrimitives(scene).count, 0); + assert.ok(pulsePrimitives(ring).ring); + assert.equal(pulsePrimitives(ring).count, 0); assert.equal(scene.preRender.numberOfListeners, 0); assert.equal(scene.postRender.numberOfListeners, 0); assert.equal(getAwarenessEchoField(), null); @@ -1161,7 +1209,7 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t loop.advance(5000); assert.equal(ring.pulseFrames(), frames); motion.set(false); - assert.equal(pulsePrimitives(scene).count, 1); + assert.equal(pulsePrimitives(ring).count, 1); assert.ok(getAwarenessEchoField()); // One media-query listener per visual; none once the ring is gone. assert.equal(motion.listeners(), 1); @@ -1170,29 +1218,31 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t }); test('a reduced-motion change reaches older Safari through addListener', (t) => { - const { scene, rendering, motion, state } = ringRendering(t, { + const ring = ringRendering(t, { legacyQuery: true, }); + const { scene, rendering, motion, state } = ring; state.subject = flight(3); rendering.renderVisual(state.subject); - assert.equal(pulsePrimitives(scene).count, 1); + assert.equal(pulsePrimitives(ring).count, 1); assert.equal(motion.listeners(), 1); motion.set(true); - assert.equal(pulsePrimitives(scene).count, 0); + assert.equal(pulsePrimitives(ring).count, 0); assert.equal(scene.preRender.numberOfListeners, 0); rendering.clearVisual(); assert.equal(motion.listeners(), 0); }); test('a refresh after reduced motion changed rebuilds the visual too', (t) => { - const { scene, rendering, motion } = ringRendering(t); + const ring = ringRendering(t); + const { scene, rendering, motion } = ring; rendering.renderVisual(flight(3)); - assert.equal(pulsePrimitives(scene).count, 1); + assert.equal(pulsePrimitives(ring).count, 1); // No change event: the next same-subject refresh notices on its own. motion.setQuietly(true); rendering.renderVisual(flight(3)); - assert.ok(pulsePrimitives(scene).ring); - assert.equal(pulsePrimitives(scene).count, 0); + assert.ok(pulsePrimitives(ring).ring); + assert.equal(pulsePrimitives(ring).count, 0); assert.equal(scene.preRender.numberOfListeners, 0); }); @@ -1232,8 +1282,10 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) => loop.advance(AWARENESS_RANGE_PULSE.settleMs); for (let step = 0; step < 40; step++) { loop.advance(100); - const { color, outlineColor } = ringColors(scene); - const flash = Math.max(restOpacity, awarenessRingFlash(loop.now())); + // As the last frame set it: the pulses draw every 80 ms, and a colour + // change asks for a frame, so the ring changes at most that often. + const { color, outlineColor } = ringColors(ring, { frame: false }); + const flash = Math.max(restOpacity, awarenessRingFlash(loop.lastFrame())); const expected = core.alpha * flash; assert.ok( Math.abs(color.alpha - expected) < 1e-9, @@ -1244,11 +1296,12 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) => }); test('without pulses the ring stays on screen at full strength', (t) => { - const { scene, rendering } = ringRendering(t, { reducedMotion: true }); + const ring = ringRendering(t, { reducedMotion: true }); + const { scene, rendering } = ring; rendering.renderVisual(flight(3)); assert.equal(scene.preRender.numberOfListeners, 0); assert.equal( - ringColors(scene).color.toCssColorString(), + ringColors(ring).color.toCssColorString(), Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ).toCssColorString(), @@ -1257,7 +1310,8 @@ test('without pulses the ring stays on screen at full strength', (t) => { }); test('the echo field follows the subject while pulses run and clears with the ring', (t) => { - const { rendering } = ringRendering(t); + const ring = ringRendering(t); + const { rendering } = ring; rendering.renderVisual(flight(3)); const field = getAwarenessEchoField(); assert.ok(field); diff --git a/src/layers/awareness/render/cesium.js b/src/layers/awareness/render/cesium.js deleted file mode 100644 index 6d530ad09..000000000 --- a/src/layers/awareness/render/cesium.js +++ /dev/null @@ -1,30 +0,0 @@ -import { BoundingSphere } from 'cesium'; - -/** - * The awareness layer's view of a Cesium globe. Its range ring, pulse disc - * and echoes are Cesium primitives (rendering.js), built on `viewer`. - */ -export function createAwarenessCesiumRenderer(viewer) { - return { - viewer, - container: viewer.container, - get canvas() { - return viewer.canvas ?? viewer.scene?.canvas; - }, - get camera() { - return viewer.camera; - }, - get trackedEntity() { - return viewer.trackedEntity; - }, - // Off while another owner (the cockpit, a gizmo drag, a drawing tool) - // has the camera: the user's gesture moves nothing then. - cameraInputsEnabled: () => - viewer.scene?.screenSpaceCameraController?.enableInputs !== false, - onFrame: (run) => viewer.scene.preRender.addEventListener(run), - frameContact: (center, radius, duration) => - viewer.camera.flyToBoundingSphere(new BoundingSphere(center, radius), { - duration, - }), - }; -} diff --git a/src/layers/awareness/render/cesiumPulses.js b/src/layers/awareness/render/cesiumPulses.js new file mode 100644 index 000000000..6a4ac8577 --- /dev/null +++ b/src/layers/awareness/render/cesiumPulses.js @@ -0,0 +1,195 @@ +// The range pulses' Cesium backend (render/pulseDisc.js): one flat, +// translucent, unpickable disc the size of the range, CONTEXT_RIM_HEIGHT_M +// above the ellipsoid, whose material draws each pulse line and the band +// behind it. Moving pulses only change its uniforms. The disc follows the +// subject by a rotation about the Earth's centre and is rebuilt only past +// `gradientRebuildM` from where it was built. +import * as Cesium from 'cesium'; +import { AWARENESS_RANGE_PULSE, CONTEXT_RIM_HEIGHT_M } from '../policy.js'; + +/** The pulse disc's Cesium material type, one cached template per page. */ +const MATERIAL_TYPE = 'GevAwarenessPulseGradient'; + +// Ellipse texture coordinates put the centre at (0.5, 0.5) and the rim at +// distance 0.5, so `r` is the fraction of the range. Each pulse is a line +// `lineWidth` CSS pixels wide whose outer `haloWidth` is a dark halo, half +// on each side, as Cesium's polyline outline material draws it, with a band +// behind it that is strongest at the line and fades to nothing `depth` +// inward. Widths come from the screen-space gradient of `r`, as Cesium's +// contour material measures its lines; without derivatives the lines are +// left out and only the band shows. +const SOURCE = ` +uniform vec4 color; +uniform vec4 lineColor; +uniform vec4 haloColor; +uniform vec4 radii; +uniform vec4 alphas; +uniform float depth; +uniform float lineWidth; +uniform float haloWidth; +vec4 over(vec4 top, vec4 under) { + float a = top.a + under.a * (1.0 - top.a); + if (a <= 0.0) return vec4(0.0); + return vec4((top.rgb * top.a + under.rgb * under.a * (1.0 - top.a)) / a, a); +} +czm_material czm_getMaterial(czm_materialInput materialInput) { + czm_material material = czm_getDefaultMaterial(materialInput); + float r = length(materialInput.st - vec2(0.5)) * 2.0; + float band = 0.0; + for (int i = 0; i < 4; i++) { + float inside = radii[i] - r; + if (inside >= 0.0 && inside <= depth) { + float t = 1.0 - inside / depth; + band += alphas[i] * t * t; + } + } + vec4 result = vec4(color.rgb, clamp(band * color.a, 0.0, 1.0)); +#if (__VERSION__ == 300 || defined(GL_OES_standard_derivatives)) + // Change in r per CSS pixel: the gradient's length per device pixel, + // times the device pixels in one CSS pixel. + float cssPx = max(length(vec2(dFdx(r), dFdy(r))), 1e-7) * czm_pixelRatio; + float outerHalf = lineWidth * 0.5; + float coreHalf = max(lineWidth - haloWidth, 0.0) * 0.5; + for (int i = 0; i < 4; i++) { + float d = abs(radii[i] - r) / cssPx; + float cover = clamp(outerHalf + 0.5 - d, 0.0, 1.0); + if (cover <= 0.0) continue; + float core = clamp(coreHalf + 0.5 - d, 0.0, 1.0); + vec4 pulse = mix(haloColor, lineColor, core); + result = over(vec4(pulse.rgb, pulse.a * cover * alphas[i]), result); + } +#endif + material.diffuse = result.rgb; + material.alpha = result.a; + return material; +} +`; + +const toColor = ([red, green, blue, alpha]) => + new Cesium.Color(red, green, blue, alpha); + +/** + * The rotation about the Earth's centre that carries `from` onto `to`, as a + * model matrix, so a disc built around `from` sits around `to`. + * @param {Cesium.Cartesian3} from Where the disc was built. + * @param {Cesium.Cartesian3} to Where it should be. + * @param {Cesium.Matrix4} [result] + * @returns {Cesium.Matrix4} + */ +export function discFollowMatrix(from, to, result = new Cesium.Matrix4()) { + const a = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3()); + const b = Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()); + const axis = Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3()); + const sin = Cesium.Cartesian3.magnitude(axis); + if (sin < 1e-12) return Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY, result); + const angle = Math.atan2(sin, Cesium.Cartesian3.dot(a, b)); + const rotation = Cesium.Matrix3.fromQuaternion( + Cesium.Quaternion.fromAxisAngle( + Cesium.Cartesian3.divideByScalar(axis, sin, axis), + angle, + ), + ); + return Cesium.Matrix4.fromRotationTranslation( + rotation, + Cesium.Cartesian3.ZERO, + result, + ); +} + +function createDisc(center, radiusM, material) { + return new Cesium.Primitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry: new Cesium.EllipseGeometry({ + center, + semiMajorAxis: radiusM, + semiMinorAxis: radiusM, + height: CONTEXT_RIM_HEIGHT_M, + granularity: Cesium.Math.toRadians( + AWARENESS_RANGE_PULSE.gradientGranularityDeg, + ), + vertexFormat: + Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat, + }), + }), + appearance: new Cesium.MaterialAppearance({ + material, + flat: true, + translucent: true, + faceForward: true, + }), + asynchronous: false, + allowPicking: false, + }); +} + +/** + * The pulse disc on a Cesium viewer, or null when its scene has no + * primitive collection: `draw({ radii, alphas, level })`, `move(center)`, + * `destroy()`. Colours are rgba arrays in 0..1. + * @param {{ viewer: object }} cesium The engine's Cesium extension. + */ +export function createAwarenessCesiumPulses( + { viewer }, + { center, radiusM, color, lineColor, haloColor, depth, lineWidth, haloWidth }, +) { + const primitives = viewer?.scene?.primitives; + if (!primitives?.add) return null; + const uniforms = { + color: toColor(color), + lineColor: toColor(lineColor), + haloColor: toColor(haloColor), + radii: new Cesium.Cartesian4(), + alphas: new Cesium.Cartesian4(), + depth, + lineWidth, + haloWidth, + }; + // A named type: Cesium caches every material type for the page's + // lifetime, so an unnamed fabric (a fresh GUID each time) would leave one + // entry per new visual. Built from the cached template, the uniforms come + // back as plain copies; restore this disc's own. + const material = new Cesium.Material({ + fabric: { type: MATERIAL_TYPE, uniforms, source: SOURCE }, + translucent: true, + }); + Object.assign(material.uniforms, uniforms); + const built = Cesium.Cartesian3.clone(center); + let disc = primitives.add(createDisc(built, radiusM, material)); + let destroyed = false; + return { + draw({ radii, alphas, level }) { + if (destroyed) return; + disc.show = level > 0; + Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii); + Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas); + }, + /** + * Centres the disc on `next`: within `gradientRebuildM` of where it was + * built by its model matrix (the ellipsoid curves a little differently + * from that sphere, tens of metres in height at 20 km), further away by + * a rebuild, which costs a few milliseconds. + */ + move(next) { + if (destroyed) return; + if ( + Cesium.Cartesian3.distance(built, next) < + AWARENESS_RANGE_PULSE.gradientRebuildM + ) { + disc.modelMatrix = discFollowMatrix(built, next, disc.modelMatrix); + return; + } + const previous = disc; + Cesium.Cartesian3.clone(next, built); + disc = primitives.add(createDisc(built, radiusM, material)); + disc.show = previous.show; + // Primitive.destroy leaves the material alone, so the new disc keeps + // sharing it. + primitives.remove(previous); + }, + destroy() { + if (destroyed) return; + destroyed = true; + primitives.remove(disc); + }, + }; +} diff --git a/src/layers/awareness/render/display.js b/src/layers/awareness/render/display.js new file mode 100644 index 000000000..849231322 --- /dev/null +++ b/src/layers/awareness/render/display.js @@ -0,0 +1,137 @@ +// The Global Context visual on the globe contract (engine.lines, +// engine.camera, engine.frames, engine.host), the same on every engine: +// the layer's view of the globe (its canvas, camera, frames, the followed +// contact and a contact flight) and the 250 km range ring, a core line over +// a wider dark halo at the rim height, with straight chords between +// positions that already follow the surface. The pulses inside the ring sit +// behind ./pulseDisc.js. Nothing here takes picks. +// +// Cesium's ring was one polyline in its outline material: a 4 px line whose +// inner 3 px are the core and whose outer pixel is the halo, half on each +// side. Lines have one colour, so here the halo is a 4 px line and the core +// a 3 px line drawn over it. + +import * as geo from '../../../geo/index.js'; +import { AWARENESS_RADIUS_M } from '../../../data/militaryAwarenessEngine.js'; +import { CYBER_SONAR_RENDER_INTERVAL_MS } from '../../../cyberSonar.js'; +import { createEngineLayerView } from '../../shared/render/engineView.js'; +import { + AWARENESS_RANGE_PULSE, + AWARENESS_RANGE_RING_STYLE, + CONTEXT_RIM_HEIGHT_M, +} from '../policy.js'; +import { awarenessRangeRingPositions } from '../rendering.js'; +import { createAwarenessPulseField } from './pulseDisc.js'; + +export const RING_ID = 'gev-awareness-rim'; +export const RING_HALO_ID = 'gev-awareness-rim-halo'; + +/** `rgba(r, g, b, a)` as an rgba array in 0..1, as Cesium reads it. */ +function rgbaArray(css) { + const [r, g, b, a = 1] = css.match(/[\d.]+/g).map(Number); + return [r / 255, g / 255, b / 255, a]; +} +const RING_CORE = rgbaArray(AWARENESS_RANGE_RING_STYLE.color); +const RING_HALO = rgbaArray(AWARENESS_RANGE_RING_STYLE.outlineColor); +const withAlpha = (rgba, alpha) => [rgba[0], rgba[1], rgba[2], rgba[3] * alpha]; + +/** + * The layer's view of `engine`. + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {{ navaraPulses?: Function }} [options] The pulses' Navara backend + * (render/navaraPulses.js), which the Navara mode passes in. + */ +export function createAwarenessDisplay(engine, { navaraPulses } = {}) { + if (!engine?.lines) + throw new TypeError('The awareness display needs a globe engine'); + const binding = createEngineLayerView(engine, { + layerId: 'military-awareness', + }); + const style = AWARENESS_RANGE_RING_STYLE; + return { + container: engine.host.container, + canvas: engine.host.canvas, + // Camera-shaped: positionWC, heading, rightWC, upWC, refreshed per frame. + camera: binding.view.camera, + get trackedEntity() { + return engine.camera.following()?.native; + }, + cameraHeight: () => engine.camera.height(), + onFrame: (run) => engine.frames.preRender.addEventListener(run), + onFrameEnd: (run) => engine.frames.postRender.addEventListener(run), + frameContact: (center, radius, duration) => + engine.camera.flyToSphere({ center, radius }, { duration }), + + /** + * The ring around `center` (on the ellipsoid); `setAlpha` flashes it. + * Every colour change asks the engine for a frame, so while it fades a + * flash changes at most once per Cyber sonar interval, the pulses' own + * cadence; full strength is set at once. + */ + createRim(center) { + const halo = engine.lines.createLine({ + id: RING_HALO_ID, + color: RING_HALO, + widthPx: style.width, + order: 0, + }); + const core = engine.lines.createLine({ + id: RING_ID, + color: RING_CORE, + widthPx: Math.max(1, style.width - style.outlineWidth), + order: 1, + }); + let alpha = 1; + let changedAt = Number.NEGATIVE_INFINITY; + const move = (position) => { + const positions = awarenessRangeRingPositions( + position, + AWARENESS_RADIUS_M, + CONTEXT_RIM_HEIGHT_M, + ); + halo.setPositions(positions); + core.setPositions(positions); + }; + move(center); + return { + move, + setAlpha(next) { + if (next === alpha) return; + const now = performance.now(); + if (next !== 1 && now - changedAt < CYBER_SONAR_RENDER_INTERVAL_MS) + return; + alpha = next; + changedAt = now; + halo.setColor(withAlpha(RING_HALO, alpha)); + core.setColor(withAlpha(RING_CORE, alpha)); + }, + destroy() { + core.destroy(); + halo.destroy(); + }, + }; + }, + + /** The pulse disc around `center`, or null on an engine without one. */ + createPulses: (center) => + createAwarenessPulseField( + engine, + { + center: geo.cartesian3.clone(center), + radiusM: AWARENESS_RADIUS_M, + // The band's own opacity, as the pulse material sets it. + color: [ + ...RING_CORE.slice(0, 3), + AWARENESS_RANGE_PULSE.gradientOpacity, + ], + lineColor: RING_CORE, + haloColor: RING_HALO, + depth: AWARENESS_RANGE_PULSE.gradientDepthFraction, + lineWidth: AWARENESS_RANGE_PULSE.width, + haloWidth: AWARENESS_RANGE_PULSE.outlineWidth, + }, + { navaraPulses }, + ), + destroy: binding.destroy, + }; +} diff --git a/src/layers/awareness/render/display.test.mjs b/src/layers/awareness/render/display.test.mjs new file mode 100644 index 000000000..ad9dcc18c --- /dev/null +++ b/src/layers/awareness/render/display.test.mjs @@ -0,0 +1,135 @@ +// The Global Context display on the engine contract, on the fake engine and +// on the Cesium engine: the camera it reads, the followed contact, a contact +// flight, its frames, the ring lines and the pulse disc only where the +// engine has a backend for it. + +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 { createAwarenessDisplay, RING_HALO_ID, RING_ID } from './display.js'; + +const HARNESSES = [ + () => ({ name: 'the fake engine', engine: createFakeEngine() }), + () => { + const { engine, viewer, primitive } = createCesiumMoversEngine(); + return { + name: 'the Cesium engine', + cesium: true, + engine, + viewer, + primitive, + }; + }, +]; + +for (const makeHarness of HARNESSES) { + const { name } = makeHarness(); + + test(`the display reads the engine's camera, frames and followed contact (${name})`, (t) => { + const h = makeHarness(); + const display = createAwarenessDisplay(h.engine); + t.after(() => { + display.destroy(); + h.engine.destroy(); + }); + h.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(3, 20, 800_000), + orientation: { heading: 0.5, pitch: -1, roll: 0 }, + }); + assert.equal(display.container, h.engine.host.container); + assert.equal(display.canvas, h.engine.host.canvas); + assert.ok(Math.abs(display.camera.heading - 0.5) < 1e-9); + assert.ok( + geo.cartesian3.distance( + display.camera.positionWC, + geo.cartesian3.fromDegrees(3, 20, 800_000), + ) < 1e-3, + ); + assert.ok(Math.abs(display.cameraHeight() - 800_000) < 1e-3); + + const contact = { gevTrackedId: 'flights:abc' }; + h.engine.camera.follow({ + native: contact, + position: () => geo.cartesian3.fromDegrees(3, 20, 9000), + viewFrom: geo.cartesian3.create(0, -2000, 1000), + }); + assert.equal(display.trackedEntity?.gevTrackedId, 'flights:abc'); + h.engine.camera.following()?.stop(); + + let frames = 0, + ends = 0; + const offFrame = display.onFrame(() => frames++); + const offEnd = display.onFrameEnd(() => ends++); + if (h.cesium) { + h.viewer.scene.preRender.raiseEvent(h.viewer.scene); + h.viewer.scene.postRender.raiseEvent(h.viewer.scene); + } else h.engine.fake.frame(); + assert.deepEqual([frames, ends], [1, 1]); + offFrame(); + offEnd(); + + const flights = []; + h.engine.camera.flyToSphere = (sphere, options) => + flights.push({ sphere, options }); + const contactAt = geo.cartesian3.fromDegrees(3, 20, 0); + display.frameContact(contactAt, 3000, 1.4); + assert.deepEqual(flights, [ + { + sphere: { center: contactAt, radius: 3000 }, + options: { duration: 1.4 }, + }, + ]); + }); + + test(`the ring is a 3 px core over a 4 px halo; pulses only with a backend (${name})`, (t) => { + const h = makeHarness(); + const display = createAwarenessDisplay(h.engine); + t.after(() => { + display.destroy(); + h.engine.destroy(); + }); + const center = geo.cartesian3.fromDegrees(3, 27, 0); + const rim = display.createRim(center); + const disc = display.createPulses(center); + if (h.cesium) { + const core = h.primitive(RING_ID); + const halo = h.primitive(RING_HALO_ID); + assert.equal(core.geometryInstances.geometry._width, 3); + assert.equal(halo.geometryInstances.geometry._width, 4); + assert.equal(core.allowPicking, false); + assert.ok(disc, 'the Cesium backend draws the pulse disc'); + const primitives = Array.from( + { length: h.viewer.scene.primitives.length }, + (_, i) => h.viewer.scene.primitives.get(i), + ); + const shown = primitives.find( + (p) => p.appearance instanceof Cesium.MaterialAppearance, + ); + assert.equal(shown.allowPicking, false); + disc.draw({ radii: [0.5, 0, 0, 0], alphas: [0.4, 0, 0, 0], level: 1 }); + assert.equal(shown.show, true); + assert.equal(shown.appearance.material.uniforms.radii.x, 0.5); + disc.draw({ radii: [0, 0, 0, 0], alphas: [0, 0, 0, 0], level: 0 }); + assert.equal(shown.show, false); + disc.destroy(); + assert.ok(shown.isDestroyed()); + } else { + assert.equal(h.engine.fake.line(RING_ID).options.widthPx, 3); + assert.equal(h.engine.fake.line(RING_HALO_ID).options.widthPx, 4); + assert.equal(disc, null, 'the fake engine has no pulse backend'); + } + rim.setAlpha(0.4); + const alpha = h.cesium + ? h.primitive(RING_ID).appearance.material.uniforms.color.alpha + : h.engine.fake.line(RING_ID).color[3]; + assert.ok(Math.abs(alpha - 0.95 * 0.4) < 1e-9); + rim.destroy(); + assert.equal( + h.engine.lines.list().filter(({ id }) => id.startsWith(RING_ID)).length, + 0, + ); + }); +} diff --git a/src/layers/awareness/render/navara.js b/src/layers/awareness/render/navara.js deleted file mode 100644 index 2a77fee87..000000000 --- a/src/layers/awareness/render/navara.js +++ /dev/null @@ -1,132 +0,0 @@ -import * as geo from '../../../geo/index.js'; -import { createEngineLayerView } from '../../shared/render/engineView.js'; -import { AWARENESS_RADIUS_M } from '../../../data/militaryAwarenessEngine.js'; -import { - AWARENESS_RANGE_PULSE, - AWARENESS_RANGE_RING_STYLE, - CONTEXT_RIM_HEIGHT_M, -} from '../policy.js'; -import { createAwarenessPulseDisc } from './navaraPulses.js'; - -/** `rgba(r, g, b, a)` as an rgba array in 0..1. */ -function rgbaArray(css) { - const [r, g, b, a = 1] = css.match(/[\d.]+/g).map(Number); - return [r / 255, g / 255, b / 255, a]; -} -const RING_CORE = rgbaArray(AWARENESS_RANGE_RING_STYLE.color); -const RING_HALO = rgbaArray(AWARENESS_RANGE_RING_STYLE.outlineColor); -const withAlpha = (rgba, alpha) => [rgba[0], rgba[1], rgba[2], rgba[3] * alpha]; - -// Sample in the subject's east/north plane, then lift each vertex onto -// the ellipsoid. This also works when a ring crosses the date line or a pole. -function rimPositions(center) { - const frame = geo.transforms.eastNorthUpToFixedFrame(center); - const positions = []; - for (let i = 0; i < 180; i++) { - const angle = (i * Math.PI) / 90; - const local = geo.cartesian3.create( - Math.cos(angle) * AWARENESS_RADIUS_M, - Math.sin(angle) * AWARENESS_RADIUS_M, - 0, - ); - const world = geo.matrix4.multiplyByPoint( - frame, - local, - geo.cartesian3.create(), - ); - const point = geo.cartographic.fromCartesian(world); - positions.push( - geo.cartesian3.fromRadians( - point.longitude, - point.latitude, - CONTEXT_RIM_HEIGHT_M, - ), - ); - } - positions.push(positions[0]); - return positions; -} - -/** - * The awareness layer's view of a Navara globe. With the globe's native view - * (`native`: view, EffectDesc and NavaraEffect) it also draws the range - * pulses; without one, the range ring alone. - */ -export function createAwarenessNavaraRenderer(engine, native = null) { - const binding = createEngineLayerView(engine, { - layerId: 'military-awareness', - }); - const style = AWARENESS_RANGE_RING_STYLE; - return { - container: engine.host.container, - canvas: engine.host.canvas, - camera: binding.view.camera, - get trackedEntity() { - return engine.camera.following()?.native; - }, - cameraHeight: () => engine.camera.height(), - onFrame: (run) => binding.view.on('preRender', run), - onFrameEnd: (run) => engine.frames.postRender.addEventListener(run), - frameContact: (center, radius, duration) => - engine.camera.flyToSphere({ center, radius }, { duration }), - /** - * The 250 km ring: a core line over a wider dark halo, as the Cesium - * ring's outline material draws it; `setAlpha` flashes it. - */ - createRim(center) { - const halo = engine.lines.createLine({ - id: 'gev-awareness-rim-halo', - color: RING_HALO, - widthPx: style.width, - arc: 'geodesic', - }); - const core = engine.lines.createLine({ - id: 'gev-awareness-rim', - color: RING_CORE, - widthPx: Math.max(1, style.width - 2 * style.outlineWidth), - arc: 'geodesic', - }); - let alpha = 1; - const move = (position) => { - const positions = rimPositions(position); - halo.setPositions(positions); - core.setPositions(positions); - }; - move(center); - return { - move, - setAlpha(next) { - if (next === alpha) return; - alpha = next; - halo.setColor(withAlpha(RING_HALO, alpha)); - core.setColor(withAlpha(RING_CORE, alpha)); - }, - destroy() { - core.destroy(); - halo.destroy(); - }, - }; - }, - // The pulse disc, when the native view can draw it. - ...(native?.view && native.EffectDesc && native.NavaraEffect - ? { - createPulses: (center) => - createAwarenessPulseDisc(engine, native, { - center, - radiusM: AWARENESS_RADIUS_M, - // The band's own opacity, as the Cesium material sets it. - color: [ - ...RING_CORE.slice(0, 3), - AWARENESS_RANGE_PULSE.gradientOpacity, - ], - lineColor: RING_CORE, - haloColor: RING_HALO, - depth: AWARENESS_RANGE_PULSE.gradientDepthFraction, - lineWidth: AWARENESS_RANGE_PULSE.width, - haloWidth: AWARENESS_RANGE_PULSE.outlineWidth, - }), - } - : {}), - destroy: binding.destroy, - }; -} diff --git a/src/layers/awareness/render/pulseDisc.js b/src/layers/awareness/render/pulseDisc.js new file mode 100644 index 000000000..7e0e3ef48 --- /dev/null +++ b/src/layers/awareness/render/pulseDisc.js @@ -0,0 +1,34 @@ +// The Global Context range pulses behind one seam: a disc the size of the +// range whose shader draws up to four pulse lines, their halos and the band +// fading inward behind each, with fronts and opacities that change every +// frame. The globe contract has no primitive for an animated fill (a ground +// set rebuilds on every commit), so each engine draws it through its own +// backend: render/cesiumPulses.js (a material disc at the rim height) and +// render/navaraPulses.js (a surface pass in Navara's view), which the Navara +// mode loads and passes in, as Navara modules reach the app only through +// its import(). This is the only awareness module that reads +// engine.extensions. +// +// An engine with no backend (the fake engine) draws no pulses, and the +// layer keeps its range ring static there. + +import { createAwarenessCesiumPulses } from './cesiumPulses.js'; + +/** + * The pulse disc around `center` on `engine`, or null when the engine has no + * backend: `draw({ radii, alphas, level })` each frame with the fronts as + * fractions of `radiusM` and their opacities, `move(center)`, `destroy()`. + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {object} disc The disc: `center`, `radiusM`, rgba `color` (the + * band), `lineColor`, `haloColor`, `depth`, `lineWidth`, `haloWidth`. + * @param {{ navaraPulses?: (engine: object, navara: object, disc: object) => object }} [backends] + * `navaraPulses` is the Navara backend's factory (render/navaraPulses.js). + */ +export function createAwarenessPulseField(engine, disc, { navaraPulses } = {}) { + const cesium = engine.extensions?.cesium; + const navara = engine.extensions?.navara; + if (cesium) return createAwarenessCesiumPulses(cesium, disc); + if (navara?.view && navara.EffectDesc && navara.NavaraEffect && navaraPulses) + return navaraPulses(engine, navara, disc); + return null; +} diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index eeeb3b76c..ebeecdfa8 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { t } from '../../i18n/index.js'; import { @@ -31,11 +30,12 @@ import { * raised to `heightM`. Each bearing steps over a sphere with the ellipsoid's * radius of curvature in that direction (Euler's formula), which keeps the * ring within ~50 m of the true geodesic radius at 250 km. - * @param {Cesium.Cartesian3} groundCenter Ring centre on the ellipsoid. + * @param {{x: number, y: number, z: number}} groundCenter Ring centre on the ellipsoid. * @param {number} radiusM Surface radius in metres. * @param {number} heightM Height of the ring above the ellipsoid. * @param {number} [segments] Chords around the ring. - * @returns {Cesium.Cartesian3[]} `segments + 1` positions; the last repeats the first. + * @returns {Array<{x: number, y: number, z: number}>} `segments + 1` + * positions; the last repeats the first. */ export function awarenessRangeRingPositions( groundCenter, @@ -43,9 +43,8 @@ export function awarenessRangeRingPositions( heightM, segments = AWARENESS_RANGE_RING_STYLE.segments, ) { - const { latitude, longitude } = - Cesium.Cartographic.fromCartesian(groundCenter); - const { x: a, z: b } = Cesium.Ellipsoid.WGS84.radii; + const { latitude, longitude } = geo.cartographic.fromCartesian(groundCenter); + const { x: a, z: b } = geo.ellipsoid.WGS84.radii; const e2 = 1 - (b * b) / (a * a); const sinLat = Math.sin(latitude); const cosLat = Math.cos(latitude); @@ -62,7 +61,7 @@ export function awarenessRangeRingPositions( const east = [-sinLon, cosLon, 0]; const positions = []; for (let i = 0; i <= segments; i++) { - const bearing = ((i % segments) / segments) * Cesium.Math.TWO_PI; + const bearing = ((i % segments) / segments) * geo.math.TWO_PI; const cosB = Math.cos(bearing); const sinB = Math.sin(bearing); const angular = @@ -74,7 +73,7 @@ export function awarenessRangeRingPositions( ); const lat = Math.atan2(z, Math.hypot(x, y)); const lon = Math.atan2(y, x); - positions.push(Cesium.Cartesian3.fromRadians(lon, lat, heightM)); + positions.push(geo.cartesian3.fromRadians(lon, lat, heightM)); } return positions; } @@ -175,177 +174,9 @@ export function awarenessRingFlash(nowMs) { ); } -/** Most pulses the pulse shader draws; its uniforms are vec4s. */ +/** Most pulses the pulse disc draws; its shader uniforms are vec4s. */ export const AWARENESS_PULSE_GRADIENT_MAX = 4; -/** The pulse disc's Cesium material type, one cached template per page. */ -const AWARENESS_PULSE_MATERIAL_TYPE = 'GevAwarenessPulseGradient'; - -// Ellipse texture coordinates put the centre at (0.5, 0.5) and the rim at -// distance 0.5, so `r` is the fraction of the range. Each pulse is a line -// `lineWidth` CSS pixels wide whose outer `haloWidth` is a dark halo, half -// on each side, as Cesium's polyline outline material draws it, with a band -// behind it that is strongest at the line and fades to nothing `depth` -// inward. Widths come from the screen-space gradient of `r`, as Cesium's -// contour material measures its lines; without derivatives the lines are -// left out and only the band shows. -const PULSE_GRADIENT_SOURCE = ` -uniform vec4 color; -uniform vec4 lineColor; -uniform vec4 haloColor; -uniform vec4 radii; -uniform vec4 alphas; -uniform float depth; -uniform float lineWidth; -uniform float haloWidth; -vec4 over(vec4 top, vec4 under) { - float a = top.a + under.a * (1.0 - top.a); - if (a <= 0.0) return vec4(0.0); - return vec4((top.rgb * top.a + under.rgb * under.a * (1.0 - top.a)) / a, a); -} -czm_material czm_getMaterial(czm_materialInput materialInput) { - czm_material material = czm_getDefaultMaterial(materialInput); - float r = length(materialInput.st - vec2(0.5)) * 2.0; - float band = 0.0; - for (int i = 0; i < 4; i++) { - float inside = radii[i] - r; - if (inside >= 0.0 && inside <= depth) { - float t = 1.0 - inside / depth; - band += alphas[i] * t * t; - } - } - vec4 result = vec4(color.rgb, clamp(band * color.a, 0.0, 1.0)); -#if (__VERSION__ == 300 || defined(GL_OES_standard_derivatives)) - // Change in r per CSS pixel: the gradient's length per device pixel, - // times the device pixels in one CSS pixel. - float cssPx = max(length(vec2(dFdx(r), dFdy(r))), 1e-7) * czm_pixelRatio; - float outerHalf = lineWidth * 0.5; - float coreHalf = max(lineWidth - haloWidth, 0.0) * 0.5; - for (int i = 0; i < 4; i++) { - float d = abs(radii[i] - r) / cssPx; - float cover = clamp(outerHalf + 0.5 - d, 0.0, 1.0); - if (cover <= 0.0) continue; - float core = clamp(coreHalf + 0.5 - d, 0.0, 1.0); - vec4 pulse = mix(haloColor, lineColor, core); - result = over(vec4(pulse.rgb, pulse.a * cover * alphas[i]), result); - } -#endif - material.diffuse = result.rgb; - material.alpha = result.a; - return material; -} -`; - -/** - * The pulses: one flat, translucent, unpickable disc the size of the range - * ring whose shader draws each pulse line and the band behind it. Drawing - * them here rather than as entities keeps them out of picking, so clicks and - * taps near a pulse still reach the contact underneath. Moving pulses only - * change its uniforms; the disc is rebuilt only when the subject moves. - * @param {Cesium.Cartesian3} groundCenter Disc centre on the ellipsoid. - * @param {Cesium.Material} material Shared pulse-gradient material. - * @returns {Cesium.Primitive} - */ -function createPulseGradient(groundCenter, material) { - return new Cesium.Primitive({ - geometryInstances: new Cesium.GeometryInstance({ - geometry: new Cesium.EllipseGeometry({ - center: groundCenter, - semiMajorAxis: AWARENESS_RADIUS_M, - semiMinorAxis: AWARENESS_RADIUS_M, - height: CONTEXT_RIM_HEIGHT_M, - granularity: Cesium.Math.toRadians( - AWARENESS_RANGE_PULSE.gradientGranularityDeg, - ), - vertexFormat: - Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat, - }), - }), - appearance: new Cesium.MaterialAppearance({ - material, - flat: true, - translucent: true, - faceForward: true, - }), - asynchronous: false, - allowPicking: false, - }); -} - -/** - * The ring's polyline geometry: `AWARENESS_RANGE_RING_STYLE.width` screen - * pixels, straight chords between the given positions (they already follow - * the surface), in the material appearance's vertex format. - * @param {Cesium.Cartesian3[]} positions Ring positions. - * @returns {object} PolylineGeometry options. - */ -export function awarenessRingGeometryOptions(positions) { - return { - positions, - width: AWARENESS_RANGE_RING_STYLE.width, - arcType: Cesium.ArcType.NONE, - vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, - }; -} - -/** - * The 250 km ring as an unpickable polyline primitive in the ring's outline - * material, so a click or tap on its edge reaches the contact underneath. - * Its positions are geometry, so a moved subject rebuilds it; the material - * is shared between builds so a flash keeps going. - * @param {Cesium.Cartesian3[]} positions Ring positions. - * @param {Cesium.Material} material Shared PolylineOutline material. - * @returns {Cesium.Primitive} - */ -function createRingPrimitive(positions, material) { - return new Cesium.Primitive({ - geometryInstances: new Cesium.GeometryInstance({ - geometry: new Cesium.PolylineGeometry( - awarenessRingGeometryOptions(positions), - ), - }), - appearance: new Cesium.PolylineMaterialAppearance({ - material, - translucent: true, - }), - asynchronous: false, - allowPicking: false, - }); -} - -/** - * The rotation about the Earth's centre that carries `from` onto `to`, as a - * model matrix, so a disc built around `from` sits around `to`. - * @param {Cesium.Cartesian3} from Where the disc was built. - * @param {Cesium.Cartesian3} to Where it should be. - * @param {Cesium.Matrix4} [result] - * @returns {Cesium.Matrix4} - */ -export function discFollowMatrix(from, to, result = new Cesium.Matrix4()) { - const a = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3()); - const b = Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()); - const axis = Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3()); - const sin = Cesium.Cartesian3.magnitude(axis); - if (sin < 1e-12) return Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY, result); - const angle = Math.atan2(sin, Cesium.Cartesian3.dot(a, b)); - const rotation = Cesium.Matrix3.fromQuaternion( - Cesium.Quaternion.fromAxisAngle( - Cesium.Cartesian3.divideByScalar(axis, sin, axis), - angle, - ), - ); - return Cesium.Matrix4.fromRotationTranslation( - rotation, - Cesium.Cartesian3.ZERO, - result, - ); -} - -/** Camera height above the ellipsoid, or NaN when the scene cannot say. */ -function cameraHeightM(scene) { - return scene.camera?.positionCartographic?.height ?? Number.NaN; -} - export function createRendering({ state: layerState, services, @@ -607,64 +438,24 @@ export function createRendering({ /** * Run the range pulses again for `activeMs`, after a camera move. Call it * during a frame (lifecycle's preRender): that frame's postRender arms the - * next request, so waking asks for no extra frame of its own. + * next request, so waking asks for no extra frame of its own. A resting + * disc was left where it was; it catches up with the subject now. */ function wakePulses() { - const wake = layerState.visual?.wake; - if (!wake) return; - wakeAwarenessPulses(wake, performance.now()); - followPulseDisc(); - } - - /** - * Keep the pulse disc centred on the subject. Within `gradientRebuildM` of - * where it was built, the disc is rotated about the Earth's centre onto - * the subject by its model matrix, which keeps it concentric with the ring - * at no build cost; the ellipsoid curves a little differently from that - * sphere, a few tens of metres in height at 20 km. Further away it is - * rebuilt, which costs a few milliseconds. - */ - function followPulseDisc() { - const { gradient, gradientCenter, center } = layerState.visual; - if (!gradient) return; - if ( - Cesium.Cartesian3.distance(gradientCenter, center) < - AWARENESS_RANGE_PULSE.gradientRebuildM - ) { - gradient.modelMatrix = discFollowMatrix( - gradientCenter, - center, - gradient.modelMatrix, - ); - return; - } - const primitives = layerState.display.viewer.scene.primitives; - layerState.visual.gradient = primitives.add( - createPulseGradient(center, gradient.appearance.material), - ); - // Primitive.destroy leaves the material alone, so the new disc keeps - // sharing it. - primitives.remove(gradient); - Cesium.Cartesian3.clone(center, gradientCenter); + const visual = layerState.visual; + if (!visual?.wake) return; + wakeAwarenessPulses(visual.wake, performance.now()); + visual.disc?.move(visual.center); } function clearVisual() { const visual = layerState.visual; - const viewer = layerState.display?.viewer; visual?.stopPulses?.(); visual?.removeMotionListener?.(); - if (visual?.gradient && viewer) - viewer.scene.primitives.remove(visual.gradient); - if (visual?.ringPrimitive && viewer) { - viewer.scene.primitives.remove?.(visual.ringPrimitive); - governorRequestRender('awareness-visual'); - } clearAwarenessEchoField(); - if (visual?.rim || (visual?.entities && viewer)) { - visual.rim?.destroy(); + if (visual?.rim) { + visual.rim.destroy(); visual.disc?.destroy(); - for (const entity of visual.entities ?? []) - viewer.entities.remove(entity); // Idle mode renders only on request; a removed ring must not linger. governorRequestRender('awareness-visual'); } @@ -777,27 +568,26 @@ export function createRendering({ } /** - * The visual through the display of an engine without a Cesium viewer: - * its range ring and, unless reduced motion is on or it cannot draw them, - * the pulses and their echoes, timed as on Cesium. + * The visual through the display: its range ring and, unless reduced + * motion is on or the engine cannot draw them, the pulses and their echoes. */ - function renderDisplayVisual(key, groundCenter, reducedMotion) { + function createVisual(key, groundCenter, reducedMotion) { const display = layerState.display; - const animate = Boolean( - !reducedMotion && - display.createPulses && - display.onFrameEnd && - display.cameraHeight, - ); - const wake = animate ? newWake() : null; + const center = geo.cartesian3.clone(groundCenter); + const rim = display.createRim(groundCenter); + const disc = reducedMotion + ? null + : (display.createPulses?.(groundCenter) ?? null); + // Pulses run from now until `wake.untilMs`, then rest. + const wake = disc ? newWake() : null; + // From a camera below the rim height the disc would sweep across the sky, + // so it is hidden there, and the pulses rest with it: no flash, no + // echoes, no frames. const { levelAt, ringAlpha, belowRim } = pulseClock( wake, () => display.cameraHeight() < CONTEXT_RIM_HEIGHT_M, ); - const center = geo.cartesian3.clone(groundCenter); - const rim = display.createRim(groundCenter); - const disc = animate ? display.createPulses(groundCenter) : null; - const stopPulses = animate + const stopPulses = disc ? runPulses({ center, wake, @@ -807,7 +597,7 @@ export function createRendering({ onFrame: display.onFrame, onFrameEnd: display.onFrameEnd, draw({ level, alpha, radii, alphas }) { - rim.setAlpha?.(alpha); + rim.setAlpha(alpha); disc.draw({ level, radii, alphas }); }, }) @@ -843,181 +633,21 @@ export function createRendering({ ) { // Move the ring only after a meaningful displacement so live tracking // does not rebuild its geometry for sub-pixel motion every 750 ms. - if ( - geo.cartesian3.distance(layerState.visual.center, groundCenter) >= 250 - ) { - if (layerState.visual.rim) { - layerState.visual.rim.move(groundCenter); - layerState.visual.disc?.move(groundCenter); - geo.cartesian3.clone(groundCenter, layerState.visual.center); - } else { - const positions = awarenessRangeRingPositions( - groundCenter, - AWARENESS_RADIUS_M, - CONTEXT_RIM_HEIGHT_M, - ); - // The echo field reads `visual.center` in place and follows on its - // own. The pulse disc follows by its model matrix and is rebuilt only - // every `gradientRebuildM`: a build costs a few milliseconds. - layerState.visual.ringPositions = positions; - const { ringPrimitive } = layerState.visual; - if (ringPrimitive) { - const primitives = layerState.display.viewer.scene.primitives; - layerState.visual.ringPrimitive = primitives.add( - createRingPrimitive(positions, ringPrimitive.appearance.material), - ); - primitives.remove(ringPrimitive); - } else layerState.visual.ring.polyline.positions.setValue(positions); - Cesium.Cartesian3.clone(groundCenter, layerState.visual.center); - // A resting disc is hidden, so it catches up when the pulses wake. - if (performance.now() < layerState.visual.wake?.untilMs) - followPulseDisc(); - } + const visual = layerState.visual; + if (geo.cartesian3.distance(visual.center, groundCenter) >= 250) { + // The echo field reads `visual.center` in place and follows on its + // own. A resting disc is hidden, so it catches up when the pulses wake. + visual.rim.move(groundCenter); + geo.cartesian3.clone(groundCenter, visual.center); + if (performance.now() < visual.wake?.untilMs) + visual.disc?.move(groundCenter); // Content actually changed — buy exactly one frame instead of holding. governorRequestRender('awareness-visual'); } return; } clearVisual(); - - // A Cesium globe draws the ring and the pulse disc as primitives below; - // another engine through its display: a ring, and pulses when it can. - const viewer = layerState.display.viewer; - if (!viewer) { - renderDisplayVisual(key, groundCenter, reducedMotion); - return; - } - const entities = []; - const scene = viewer.scene; - // A viewer without a primitive collection or a preRender hook (headless - // tools, partial stubs) gets the static ring only, as the Cyber sonar - // scene does. So does a viewer who prefers reduced motion. - const animate = Boolean( - !reducedMotion && - scene?.primitives?.add && - scene?.preRender?.addEventListener && - scene?.postRender?.addEventListener, - ); - // Pulses run from now until `wake.untilMs`, then rest. - const wake = animate ? newWake() : null; - // From a camera below the rim height the disc would sweep across the sky, - // so it is hidden there, and the pulses rest with it: no flash, no - // echoes, no frames. - const { levelAt, ringAlpha, belowRim } = pulseClock( - wake, - () => cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M, - ); - - // A polyline, not an ellipse outline: WebGL draws ellipse outlines at - // 1 px whatever `outlineWidth` says, and the ring needs a halo. - const ring = AWARENESS_RANGE_RING_STYLE; - const core = Cesium.Color.fromCssColorString(ring.color); - const halo = Cesium.Color.fromCssColorString(ring.outlineColor); - const ringPositions = awarenessRangeRingPositions( - groundCenter, - AWARENESS_RADIUS_M, - CONTEXT_RIM_HEIGHT_M, - ); - let ringEntity = null; - let ringMaterial = null; - let ringPrimitive = null; - if (scene?.primitives?.add) { - ringMaterial = Cesium.Material.fromType('PolylineOutline', { - color: core.clone(), - outlineColor: halo.clone(), - outlineWidth: ring.outlineWidth, - }); - ringPrimitive = - scene.primitives.add( - createRingPrimitive(ringPositions, ringMaterial), - ) ?? null; - } - if (!ringPrimitive) { - // Headless tools and partial stubs have no primitive collection; they - // get the static ring as an entity. - ringEntity = viewer.entities.add({ - polyline: { - positions: new Cesium.ConstantProperty(ringPositions), - width: ring.width, - arcType: Cesium.ArcType.NONE, - material: new Cesium.PolylineOutlineMaterialProperty({ - color: core, - outlineColor: halo, - outlineWidth: ring.outlineWidth, - }), - }, - }); - entities.push(ringEntity); - } - - const center = Cesium.Cartesian3.clone(groundCenter); - let gradient = null; - let stopPulses = null; - if (animate) { - const pulse = AWARENESS_RANGE_PULSE; - const count = Math.min(pulse.count, AWARENESS_PULSE_GRADIENT_MAX); - const uniforms = { - color: core.clone(), - lineColor: core.clone(), - haloColor: halo.clone(), - radii: new Cesium.Cartesian4(), - alphas: new Cesium.Cartesian4(), - depth: pulse.gradientDepthFraction, - lineWidth: pulse.width, - haloWidth: pulse.outlineWidth, - }; - uniforms.color.alpha = pulse.gradientOpacity; - // A named type: Cesium caches every material type for the page's - // lifetime, so an unnamed fabric (a fresh GUID each time) would leave - // one entry per new visual. Built from the cached template, the - // uniforms come back as plain copies; restore this visual's own. - const material = new Cesium.Material({ - fabric: { - type: AWARENESS_PULSE_MATERIAL_TYPE, - uniforms, - source: PULSE_GRADIENT_SOURCE, - }, - translucent: true, - }); - Object.assign(material.uniforms, uniforms); - gradient = scene.primitives.add( - createPulseGradient(groundCenter, material), - ); - stopPulses = runPulses({ - center, - wake, - levelAt, - ringAlpha, - belowRim, - onFrame: (run) => scene.preRender.addEventListener(run), - onFrameEnd: (run) => scene.postRender.addEventListener(run), - draw({ level, alpha, radii, alphas }) { - ringMaterial.uniforms.color.alpha = core.alpha * alpha; - ringMaterial.uniforms.outlineColor.alpha = halo.alpha * alpha; - // The disc is looked up each frame: a subject move swaps it. - const disc = layerState.visual?.gradient; - if (disc) disc.show = level > 0; - Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii); - Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas); - }, - }); - } - - layerState.visual = { - key, - entities, - ring: ringEntity, - ringPrimitive, - ringPositions, - center, - gradient, - gradientCenter: Cesium.Cartesian3.clone(groundCenter), - reducedMotion, - wake, - stopPulses, - removeMotionListener: watchReducedMotion(), - }; - governorRequestRender('awareness-visual'); + createVisual(key, groundCenter, reducedMotion); } return { ensureDirectionOverlay, diff --git a/src/layers/infrastructure/index.js b/src/layers/infrastructure/index.js index a25b8f5ef..e269afa32 100644 --- a/src/layers/infrastructure/index.js +++ b/src/layers/infrastructure/index.js @@ -4,12 +4,11 @@ import { mapAnalystRecord, parseGeojsonLines, } from '../../sources/infrastructureData.js'; -import { - createLocalGeoJsonLayer, - localDatasetError, -} from '../../data/localGeojsonCore.js'; +import { localDatasetError } from '../../data/localGeojsonCore.js'; +import { createInfrastructureDisplay } from './render/display.js'; -// Split multipart features exactly as the Cesium entity collection does, with stable IDs on both globes. +// Split multipart features as Cesium's GeoJsonDataSource names its entities +// (a repeated id gets `_2`, `_3`, ...), with stable ids on every globe. function dataset(raw) { const features = [], records = [], @@ -61,11 +60,18 @@ function dataset(raw) { return { features, records }; } -/** The parsed local file and the selected feature outlive any one scene. */ +/** + * One datacenter or dam layer. The parsed local file and the selected + * feature outlive any one scene; `createDisplay(engine, config, services)` + * draws them on each attached engine (show(dataset), select(featureId), + * hide(), destroy(), lod()), by default the contract display + * (render/display.js). + */ export function createInfrastructureSessionLayer( config, services, - nativeFactory, + createDisplay = (engine) => + createInfrastructureDisplay(engine, { config, services }), ) { const { id, name, icon, source, url } = config; let data = null, @@ -129,27 +135,12 @@ export function createInfrastructureSessionLayer( source, updateInterval: 0, statsRefreshInterval: 1000, - init(viewer, options = {}) { - if (viewer || options.engine) return this.attachRenderer(viewer, options); + init(_viewer, options = {}) { + if (options.engine) return this.attachRenderer(_viewer, options); }, - attachRenderer(viewer, { engine = null, signal = null } = {}) { + attachRenderer(_viewer, { engine = null, signal = null } = {}) { if (display) throw Error('Infrastructure display already attached'); - if (viewer) { - const legacy = createLocalGeoJsonLayer( - { ...config, readFeatures: async () => (await read()).features }, - services, - ); - display = { - show: async () => { - await legacy.enable(viewer); - error = legacy.getStats().error; - }, - select: (key) => legacy.restoreFeatureSelection(viewer, key), - hide: () => legacy.disable(viewer), - destroy: () => legacy.destroy(viewer), - lod: legacy.getLodDiagnostics, - }; - } else display = nativeFactory(engine, config, services); + display = engine ? createDisplay(engine, config, services) : null; // The dataset load belongs to the session and keeps running; only this // scene's wait for it ends when the scene is released. A released // display is destroyed and ignores what arrives later. @@ -168,8 +159,7 @@ export function createInfrastructureSessionLayer( display.destroy(); display = null; }, - enable(viewer) { - if (!display && viewer) this.attachRenderer(viewer); + enable() { enabled = true; return show(); }, diff --git a/src/layers/infrastructure/render/display.js b/src/layers/infrastructure/render/display.js new file mode 100644 index 000000000..cf663af35 --- /dev/null +++ b/src/layers/infrastructure/render/display.js @@ -0,0 +1,774 @@ +// The datacenters and dams on the globe contract (engine.movers, +// engine.lines, engine.ground, engine.surface), the same on every engine. +// Each point or polygon feature has a reference: +// - a 10 px tip disc in the layer colour with a 2 px black outline, drawn +// over the scene, about 65 px above the feature's base; +// - a 3.5 px stem in the layer colour from the base to the tip, hidden by +// the scene; +// - for a Point feature, a disc of the 8 px GeoJSON pin's width at the base. +// Polygon footprints are one engine.ground polygon set in the layer colour +// at 0.3, without an outline (Cesium draws none on a clamped polygon); line +// features are one engine.ground corridor set, 2 px in the layer colour. +// The camera-height LOD (src/data/localGeojsonLod.js) picks the references +// and footprints that draw; it runs again after a camera move, and during +// continuous motion on its probe window. Once the camera is within 75 km and +// the ground under a reference is in view, the base moves onto the sampled +// surface. The references' cards go to the shared world-overlay host. A +// click on any of them selects the feature and flies there. + +import * as geo from '../../../geo/index.js'; +import { isPointerFree } from '../../../data/inputOwnership.js'; +import { + applyInfraEvictionGrace, + selectInfraLod, + shouldRecomputeInfraLod, +} from '../../../data/localGeojsonLod.js'; +import { + LOCAL_OVERLAY_COHORT_LIMIT, + createLocalInfrastructureOverlayEntry, + createLocalInfrastructureOverlayPublisher, + labelPriorityFromProperties, + localInfrastructureOverlayCopy, + selectLocalInfrastructureOverlayCohort, +} from '../../../data/localGeojsonCore.js'; +import { rgbaFromCss } from '../../shared/color.js'; + +const DEFAULT_LABEL_MAX = 900; +const DEFAULT_LABEL_GRID_PX = 132; +/** The reference walk runs at most this often. */ +export const WALK_INTERVAL_MS = 450; +const TIP_PX = 10; +const TIP_OUTLINE_PX = 2; +const STEM_PX = 3.5; +// The 8 px GeoJSON pin is about 5 px wide. +const PIN_PX = 5; +const FOOTPRINT_ALPHA = 0.3; +const LINE_PX = 2; +// The tip stands this many pixels above the base. +const STEM_TARGET_PX = 65; +// Before the first walk sizes it. +const INITIAL_TIP_M = 2000; +const FLY_HEIGHT_M = 5000; +const FLY_SECONDS = 1.5; +/** + * The base starts on the ellipsoid. High features (dams in river canyons) + * sit hundreds of metres above it, which buries a short close-in stem, so + * once the camera is this near the surface height under each base is + * sampled once and the base moves onto it. + */ +export const GROUND_SAMPLE_MAX_DISTANCE_M = 75000; +export const GROUND_SAMPLE_RETRY_MS = 2000; +const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000; +/** + * A sampleable scene can still never answer under a feature; each retry + * asks for the frame the next one needs, so after this many armed retries + * without a newly grounded base the layer stops asking. A camera move, a + * grounded base or sampling becoming supported again re-opens the budget. + * 30 × 2 s is far longer than a tile stream-in. + */ +export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30; +/** Tip moves below this are camera noise and are not drawn. */ +export const LOCAL_STEM_TIP_EPSILON_M = 0.5; +const TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2; + +const finite = (value) => Number.isFinite(value); +const lonLat = (position) => [position[0], position[1]]; + +/** + * Cesium's PerspectiveFrustum.fov: the horizontal angle on a wide canvas, + * the vertical one on a tall canvas (what the stem sizing reads). + */ +function frustumFov({ fovy, aspectRatio }) { + const aspect = aspectRatio ?? 1; + return aspect > 1 ? 2 * Math.atan(Math.tan(fovy / 2) * aspect) : fovy; +} + +/** + * The datacenter or dam display: `{ show(dataset), select(featureId), + * hide(), destroy(), lod() }`. `dataset` is the session's + * `{ features, records }` (src/layers/infrastructure/index.js). + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {{ config: object, services: object }} options The layer's + * descriptor (src/data/infrastructure.js) and the application operations + * (docs/INFRASTRUCTURE-LAYERS.md). + */ +export function createInfrastructureDisplay( + engine, + { config, services = {} } = {}, +) { + if (!engine?.movers || !engine?.lines || !engine?.ground) + throw new TypeError('The infrastructure display needs a globe engine'); + const { + id, + name, + source, + color, + osmDerived = false, + labels = true, + labelMax = DEFAULT_LABEL_MAX, + labelGridPx = DEFAULT_LABEL_GRID_PX, + } = config; + const css = rgbaFromCss(color); + const rgba = css.rgba(); + const footprintFill = [css.red, css.green, css.blue, FOOTPRINT_ALPHA]; + const publisher = createLocalInfrastructureOverlayPublisher({ + sourceId: id, + host: services.overlayHost, + }); + const key = (featureId) => `${id}:${featureId}`; + const wake = (reason = id) => engine.render.request(reason); + + // Records in dataset order (their tip index), by feature id. + const rows = new Map(); + // Line features by id, for clicks. + const lineFeatures = new Map(); + // Drawn object ids (sets and lines) whose picks are this layer's. + const owners = new Set(); + // record id → its stem, while the record is drawn. + const stems = new Map(); + const removers = []; + let dataset = null, + tips = null, + pins = null, + footprints = null, + corridors = null, + exclude = [], + destroyed = false; + // The LOD selection. + let active = new Set(), + grace = new Map(), + budget = 0, + computed = false, + topology = null, + lastProbeMs = Number.NEGATIVE_INFINITY; + const lastLodCamera = geo.cartesian3.create(); + // The walk. + let dirty = true, + lastWalk = Number.NEGATIVE_INFINITY, + walkCount = 0; + // The ground-sample retry. + let retryTimer = null, + retryArms = 0, + lastCapability = null; + // Per walk: the view-projection matrix, computed on first use. + const viewProjection = geo.matrix4.create(); + let viewProjectionWalk = -1; + const scratchCarto = geo.cartographic.create(0, 0, 0); + const scratchLow = geo.cartesian3.create(); + const scratchHigh = geo.cartesian3.create(); + const scratchWindow = geo.cartesian2.create(0, 0); + + const photoreal = () => engine.maps?.photorealActive?.() === true; + + function scheduleGroundRetry() { + if (retryTimer || !dataset || !engine.surface.supported) return; + if (retryArms >= GROUND_SAMPLE_MAX_ARMED_RETRIES) return; + retryArms += 1; + retryTimer = setTimeout(() => { + retryTimer = null; + if (dataset && !destroyed) wake(`local-ground-retry:${id}`); + }, GROUND_SAMPLE_RETRY_MS); + } + + function clearGroundRetry() { + retryArms = 0; + lastCapability = null; + clearTimeout(retryTimer); + retryTimer = null; + } + + /** The record's vertical at `height` metres above the ellipsoid. */ + function vertical(row, height, result) { + scratchCarto.longitude = row.carto.longitude; + scratchCarto.latitude = row.carto.latitude; + scratchCarto.height = height; + return geo.cartographic.toCartesian(scratchCarto, undefined, result); + } + + // Whether both points are outside one side of the view frustum: the + // clip-space tests x ≥ -w, x ≤ w, y ≥ -w, y ≤ w and w ≥ 0 (in front of + // the camera), each linear in the world position. + function bothOutside(low, high) { + if (viewProjectionWalk !== walkCount) { + engine.projector.viewProjection(viewProjection); + viewProjectionWalk = walkCount; + } + const m = viewProjection; + const ax = m[0] * low.x + m[4] * low.y + m[8] * low.z + m[12]; + const ay = m[1] * low.x + m[5] * low.y + m[9] * low.z + m[13]; + const aw = m[3] * low.x + m[7] * low.y + m[11] * low.z + m[15]; + const bx = m[0] * high.x + m[4] * high.y + m[8] * high.z + m[12]; + const by = m[1] * high.x + m[5] * high.y + m[9] * high.z + m[13]; + const bw = m[3] * high.x + m[7] * high.y + m[11] * high.z + m[15]; + return ( + (ax + aw < 0 && bx + bw < 0) || + (aw - ax < 0 && bw - bx < 0) || + (ay + aw < 0 && by + bw < 0) || + (aw - ay < 0 && bw - by < 0) || + (aw < 0 && bw < 0) + ); + } + + /** + * Whether the camera sees the ground under the record: inside the view + * frustum and above the horizon. A sample of unseen ground passes through + * the Earth (about -6,344 km near Ashburn on Esri) and fails on every + * retry, each one a full offscreen render on Cesium. Unsampled bases sit + * on the ellipsoid, so the test uses the terrain height when there is one + * (not under photoreal tiles, which hide the globe); otherwise it tests + * the vertical across ±GROUND_SAMPLE_MAX_ABS_HEIGHT_M, every height a + * sample can accept. + */ + function groundInView(row) { + const terrain = photoreal() + ? undefined + : engine.surface.terrainHeight(row.carto); + const known = + finite(terrain) && Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M; + const low = vertical( + row, + known ? terrain : -GROUND_SAMPLE_MAX_ABS_HEIGHT_M, + scratchLow, + ); + const high = known + ? low + : vertical(row, GROUND_SAMPLE_MAX_ABS_HEIGHT_M, scratchHigh); + if (bothOutside(low, high)) return false; + // The horizon hides every point below the ellipsoid from a camera near + // it, so ground below 0 m is tested at 0 m; unknown ground at the top + // of the vertical. + const horizonHeight = known + ? Math.max(terrain, 0) + : GROUND_SAMPLE_MAX_ABS_HEIGHT_M; + return engine.projector.horizonVisible( + vertical(row, horizonHeight, scratchHigh), + ); + } + + function setGroundHeight(row, height) { + row.groundHeight = height; + geo.cartesian3.fromRadians( + row.carto.longitude, + row.carto.latitude, + height, + undefined, + row.base, + ); + row.baseMoved = true; + } + + // The scene height under the record, once, while the visible surface has + // loaded. A coarse sample can lie below the loaded terrain: the terrain is + // the floor then (not under photoreal tiles), while roofs and valid + // below-sea-level heights stay. + function sampleGround(row, now) { + if (row.groundSampled || !engine.surface.supported) return false; + if (now - row.lastSample < GROUND_SAMPLE_RETRY_MS) return false; + if (!groundInView(row)) return false; + row.lastSample = now; + const globe = !photoreal(); + if (globe && !engine.surface.tilesLoaded()) return false; + // The stem stands on the sample point: out of the sample. + const stem = stems.get(row.id); + stem?.line.show(false); + let sampled; + try { + sampled = engine.surface.sampleHeight(row.carto, exclude); + } finally { + stem?.line.show(true); + } + if (!finite(sampled) || Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M) + return false; + const terrain = globe ? engine.surface.terrainHeight(row.carto) : undefined; + if (finite(terrain) && Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M) + sampled = Math.max(sampled, terrain); + row.groundSampled = true; + setGroundHeight(row, sampled); + return true; + } + + // A sampled base follows refined terrain upward on the bounded walks, + // without another sample. + function refreshTerrainFloor(row, camera) { + if (!row.groundSampled || photoreal() || !engine.surface.tilesLoaded()) + return false; + if ( + geo.cartesian3.distance(camera, row.base) >= GROUND_SAMPLE_MAX_DISTANCE_M + ) + return false; + const height = engine.surface.terrainHeight(row.carto); + if ( + !finite(height) || + Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M || + height <= row.groundHeight + ) + return false; + setGroundHeight(row, height); + return true; + } + + // The tip STEM_TARGET_PX above the base at the camera's distance; a move + // under LOCAL_STEM_TIP_EPSILON_M is not drawn. + function sizeStem(row, now, pose, view, knownDistance = null) { + const distance = finite(knownDistance) + ? knownDistance + : geo.cartesian3.distance(pose.position, row.base); + if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) sampleGround(row, now); + const tipHeight = + row.groundHeight + + Math.max(distance, 1) * + 2 * + Math.tan(view.fov / 2) * + (STEM_TARGET_PX / view.height); + geo.cartesian3.fromRadians( + row.carto.longitude, + row.carto.latitude, + tipHeight, + undefined, + row.nextTip, + ); + if (geo.cartesian3.distanceSquared(row.tip, row.nextTip) <= TIP_EPSILON_SQ) + return false; + geo.cartesian3.clone(row.nextTip, row.tip); + row.tipMoved = true; + return true; + } + + function buildFootprints() { + const items = []; + for (const row of rows.values()) + if (row.rings && active.has(row.id)) + items.push({ + rings: row.rings, + fill: footprintFill, + pickId: row.pickId, + }); + footprints.replace(items); + footprints.commit(); + } + + function stemOf(row) { + let stem = stems.get(row.id); + if (!stem) { + const lineId = `${id}-stem-${row.id}`; + stem = { + id: lineId, + line: engine.lines.createLine({ + id: lineId, + widthPx: STEM_PX, + color: rgba, + pickId: row.pickId, + }), + }; + stem.line.setPositions([row.base, row.tip]); + stem.base = geo.cartesian3.clone(row.base); + stem.tip = geo.cartesian3.clone(row.tip); + owners.add(lineId); + stems.set(row.id, stem); + } + return stem; + } + + function dropStem(rowId) { + const stem = stems.get(rowId); + if (!stem) return; + stem.line.destroy(); + owners.delete(stem.id); + stems.delete(rowId); + } + + // The LOD selection, from every record's distance and horizon. + function selectLod(pose, now) { + const candidates = []; + for (const row of rows.values()) + candidates.push({ + id: row.id, + priority: row.priority, + distanceM: geo.cartesian3.distance(pose.position, row.base), + inView: engine.projector.horizonVisible(row.base), + }); + const selection = selectInfraLod(candidates, { + cameraHeightM: pose.cartographic?.height, + incumbentIds: active, + }); + const kept = applyInfraEvictionGrace({ + selectedIds: selection.activeIds, + builtIds: [...active], + graceState: grace, + nowMs: now, + activeLimit: selection.budget.activeLimit, + }); + active = new Set(kept.keepIds); + grace = kept.graceState; + budget = selection.budget.activeLimit; + computed = true; + // Travel for the motion probe counts from this selection. + geo.cartesian3.clone(pose.position, lastLodCamera); + lastProbeMs = now; + const next = kept.keepIds.slice().sort().join('\n'); + if (next !== topology) { + topology = next; + buildFootprints(); + } + } + + // The reference walk, on WALK_INTERVAL_MS: the LOD after a camera move, + // shows, ground samples, tips and stems, and the card cohort. + function walk() { + if (!dataset) return; + const now = performance.now(); + if (now - lastWalk < WALK_INTERVAL_MS) return; + lastWalk = now; + walkCount++; + const pose = engine.camera.pose(); + const camera = pose.position; + const size = engine.host.size(); + const view = { fov: frustumFov(pose), height: size.height || 1080 }; + // Motion without a moveEnd (a follow, Cockpit, a route flight). + if (!dirty && computed && rows.size) { + const probe = shouldRecomputeInfraLod({ + nowMs: now, + lastProbeMs, + movedSqM: geo.cartesian3.distanceSquared(camera, lastLodCamera), + cameraHeightM: pose.cartographic?.height, + }); + lastProbeMs = probe.lastProbeMs; + if (probe.recompute) dirty = true; + } + const refresh = dirty; + // Without sampling no retry is armed and no per-record work is spent. + const canSample = engine.surface.supported === true; + // Sampling that comes back re-opens a spent budget on a parked camera. + if (canSample && lastCapability === false) retryArms = 0; + lastCapability = canSample; + let retryPending = false; + let grounded = false; + if (refresh && rows.size) selectLod(pose, now); + let tipsChanged = false; + let pinsChanged = false; + const visible = []; + for (const row of rows.values()) { + const isActive = !computed || active.has(row.id); + const isVisible = isActive && engine.projector.horizonVisible(row.base); + if (row.show !== isVisible) { + row.show = isVisible; + tips.set(row.index, { show: isVisible }); + tipsChanged = true; + if (row.pinIndex >= 0) { + pins.set(row.pinIndex, { show: isVisible }); + pinsChanged = true; + } + } + if (!isVisible) dropStem(row.id); + if (!isActive) continue; + const wasSampled = row.groundSampled; + const floorChanged = refreshTerrainFloor(row, camera); + if (refresh || floorChanged) sizeStem(row, now, pose, view); + else if ( + canSample && + !row.groundSampled && + now - row.lastSample >= GROUND_SAMPLE_RETRY_MS + ) { + const distance = geo.cartesian3.distance(camera, row.base); + if (distance < GROUND_SAMPLE_MAX_DISTANCE_M && sampleGround(row, now)) + sizeStem(row, now, pose, view, distance); + } + if (!wasSampled && row.groundSampled) grounded = true; + // An ungrounded base in range and in view: the retry needs a frame + // the idle governor would never produce. + if ( + canSample && + !row.groundSampled && + !retryPending && + geo.cartesian3.distance(camera, row.base) < + GROUND_SAMPLE_MAX_DISTANCE_M && + groundInView(row) + ) + retryPending = true; + if (row.tipMoved) { + row.tipMoved = false; + tips.set(row.index, { position: row.tip }); + tipsChanged = true; + } + if (row.baseMoved) { + row.baseMoved = false; + if (row.pinIndex >= 0) { + pins.set(row.pinIndex, { position: row.base }); + pinsChanged = true; + } + } + if (isVisible) { + const stem = stems.get(row.id); + if (!stem) stemOf(row); + else if ( + !geo.cartesian3.equals(stem.tip, row.tip) || + !geo.cartesian3.equals(stem.base, row.base) + ) { + stem.line.setPositions([row.base, row.tip]); + geo.cartesian3.clone(row.base, stem.base); + geo.cartesian3.clone(row.tip, stem.tip); + } + if (row.entry) visible.push(row); + } + } + dirty = false; + if (tipsChanged) tips.commit(); + if (pinsChanged) pins.commit(); + // Real progress re-opens the budget for the records still waiting. + if (grounded) retryArms = 0; + if (retryPending) scheduleGroundRetry(); + publisher.publish( + selectLocalInfrastructureOverlayCohort(visible, { + maxEntries: labelMax, + gridPx: labelGridPx, + width: size.width || 0, + height: size.height || 0, + cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT, + project: (row) => + engine.projector.worldToWindow(row.tip, scratchWindow), + }), + ); + } + + function select(featureId) { + const row = rows.get(featureId); + if (!row) return false; + services.selectEntityContext?.(row); + return true; + } + + function flyTo(lon, lat) { + // Camera input would cancel the flight. + engine.input.setCameraInputs(false); + const restore = () => engine.input.setCameraInputs(true); + engine.camera.flyTo({ + destination: geo.cartesian3.fromDegrees(lon, lat, FLY_HEIGHT_M), + duration: FLY_SECONDS, + complete: restore, + cancel: restore, + }); + } + + function onClick(event) { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree()) return; + const picked = event.picked; + if (!dataset || !picked || !owners.has(picked.owner)) return; + const featureId = String(picked.id).slice(id.length + 1); + const row = rows.get(featureId); + if (row) { + select(featureId); + flyTo(row.lon, row.lat); + return; + } + const feature = lineFeatures.get(featureId); + const [lon, lat] = feature?.geometry.coordinates[0] ?? []; + if (finite(lon) && finite(lat)) flyTo(lon, lat); + } + + function hide() { + const shown = Boolean(dataset); + services.hideOsmCredit?.(engine, id); + clearGroundRetry(); + for (const remove of removers.splice(0)) remove(); + for (const rowId of [...stems.keys()]) dropStem(rowId); + for (const set of [tips, pins, footprints, corridors]) set?.destroy(); + tips = pins = footprints = corridors = null; + exclude = []; + owners.clear(); + if (shown) services.removeEntityContextsForLayer?.(id); + rows.clear(); + lineFeatures.clear(); + dataset = null; + active = new Set(); + grace = new Map(); + budget = 0; + computed = false; + topology = null; + lastProbeMs = Number.NEGATIVE_INFINITY; + dirty = true; + lastWalk = Number.NEGATIVE_INFINITY; + publisher.hide(); + services.clearSelectedEntityContextForLayer?.(id); + if (shown) wake(`${id}-hide`); + } + + function build(next) { + let pinCount = 0; + const corridorItems = []; + for (const feature of next.features) + if (feature.geometry?.type === 'LineString') { + lineFeatures.set(feature.id, feature); + corridorItems.push({ + positions: feature.geometry.coordinates.map(lonLat), + color: rgba, + widthPx: LINE_PX, + pickId: key(feature.id), + }); + } + for (const input of next.records) { + const carto = geo.cartographic.fromDegrees(input.lon, input.lat); + const base = geo.cartesian3.fromDegrees(input.lon, input.lat); + const tip = geo.cartesian3.fromDegrees( + input.lon, + input.lat, + INITIAL_TIP_M, + ); + const priority = labelPriorityFromProperties(input.properties, id); + const geometry = input.geometry; + const row = { + ...input, + index: rows.size, + pinIndex: geometry?.type === 'Point' ? pinCount++ : -1, + rings: + geometry?.type === 'Polygon' + ? geometry.coordinates.map((ring) => ring.map(lonLat)) + : null, + pickId: key(input.id), + carto, + base, + tip, + nextTip: geo.cartesian3.clone(tip), + priority, + groundHeight: 0, + groundSampled: false, + lastSample: Number.NEGATIVE_INFINITY, + // As a Cesium entity's: shown until the first walk decides; the + // context store reads it (isContextRecordActive). + show: true, + tipMoved: false, + baseMoved: false, + entry: labels + ? createLocalInfrastructureOverlayEntry({ + id: input.id, + layerId: id, + position: tip, + properties: input.properties, + priority, + accent: color, + }) + : null, + }; + rows.set(row.id, row); + services.registerEntityContext?.(row, { + id: row.pickId, + layerId: id, + layerName: name, + source, + label: localInfrastructureOverlayCopy(row.properties, id).title, + properties: row.properties, + latitude: Number(row.lat.toFixed(6)), + longitude: Number(row.lon.toFixed(6)), + }); + } + corridors = engine.ground.createSet({ + id: `${id}-lines`, + kind: 'corridor', + order: 1, + pickable: true, + }); + owners.add(corridors.id); + corridors.replace(corridorItems); + corridors.commit(); + footprints = engine.ground.createSet({ + id: `${id}-footprints`, + kind: 'polygon', + pickable: true, + }); + owners.add(footprints.id); + tips = engine.movers.createSet({ + id: `${id}-tips`, + kind: 'point', + capacity: Math.max(1, rows.size), + pickable: true, + // Never hidden by the scene: the horizon is the walk's. + depthTest: 'always', + point: { + sizePx: TIP_PX, + outlinePx: TIP_OUTLINE_PX, + outlineColor: [0, 0, 0, 1], + }, + }); + owners.add(`${id}-tips`); + for (const row of rows.values()) + tips.set(row.index, { + position: row.tip, + color: rgba, + show: true, + pickId: row.pickId, + }); + tips.setCount(rows.size); + tips.commit(); + exclude = [tips]; + if (pinCount) { + pins = engine.movers.createSet({ + id: `${id}-pins`, + kind: 'point', + capacity: pinCount, + pickable: true, + point: { sizePx: PIN_PX, outlinePx: 0 }, + }); + owners.add(`${id}-pins`); + for (const row of rows.values()) + if (row.pinIndex >= 0) + pins.set(row.pinIndex, { + position: row.base, + color: rgba, + show: true, + pickId: row.pickId, + }); + pins.setCount(pinCount); + pins.commit(); + exclude.push(pins); + } + } + + return { + /** Draws a dataset; the first walk runs on the next frame. */ + show(next) { + if (destroyed || next === dataset) return; + if (dataset) hide(); + try { + dataset = next; + build(next); + removers.push( + engine.frames.preRender.addEventListener(walk), + engine.camera.moveEnd.addEventListener(() => { + dirty = true; + lastWalk = Number.NEGATIVE_INFINITY; + // A camera move is a fresh situation with free frames. + retryArms = 0; + wake(); + }), + engine.input.on('click', onClick, { pick: true }), + ); + publisher.show(); + if (osmDerived && next.features.length) + services.showOsmCredit?.(engine, id); + wake(); + } catch (error) { + hide(); + throw error; + } + }, + select, + hide, + destroy() { + if (destroyed) return; + hide(); + publisher.destroy(); + destroyed = true; + }, + /** + * Globe-LOD state for QA (scripts/qa-infra-lod.mjs) and tests: `total` + * records, `active` the ones the LOD keeps, `budgetLimit` the last + * camera-height band's cap, `computed` false until the first walk. + */ + lod: () => ({ + total: rows.size, + active: active.size, + budgetLimit: budget, + computed, + }), + }; +} diff --git a/src/layers/infrastructure/render/display.test.mjs b/src/layers/infrastructure/render/display.test.mjs new file mode 100644 index 000000000..4125f55fa --- /dev/null +++ b/src/layers/infrastructure/render/display.test.mjs @@ -0,0 +1,1345 @@ +// The datacenter and dam display on the engine contract, on the fake engine +// and on the Cesium engine: what it draws (tips, stems, pins, footprints, +// lines) and their styles; the camera-height LOD; the card cohort and its +// publish skip; tip sizing and the stem updates; ground sampling, the +// terrain floor and the bounded retry; clicks, selection and teardown. +// Each harness drives preRender and moveEnd by hand, scripts the surface, +// records what the ground sets, lines and flights were given, and clicks +// with a pick of a mover instance, a ground item or a line. + +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 { createFakeEventHandlers } from '../../../globe/cesium/testing/fakeViewer.mjs'; +import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs'; +import { claimPointer, releasePointer } from '../../../data/inputOwnership.js'; +import { + INFRA_LOD_ACTIVE_MAX, + INFRA_LOD_ACTIVE_MIN, +} from '../../../data/localGeojsonLod.js'; +import { LOCAL_OVERLAY_COHORT_LIMIT } from '../../../data/localGeojsonCore.js'; +import { rgbaFromCss } from '../../shared/color.js'; +import { + GROUND_SAMPLE_MAX_ARMED_RETRIES, + LOCAL_STEM_TIP_EPSILON_M, + createInfrastructureDisplay, +} from './display.js'; + +// GroundPolylinePrimitive checks its render state's line width against the +// context's limits, which a stub scene has none of. +Cesium.ContextLimits._minimumAliasedLineWidth = 1; +Cesium.ContextLimits._maximumAliasedLineWidth = 10; + +const DAMS = { + id: 'local-dams', + name: 'Dams', + color: '#0088ff', + source: 'USACE', + osmDerived: true, + labels: true, + labelMax: 900, + labelGridPx: 132, +}; +const DATACENTERS = { + id: 'local-datacenters', + name: 'Datacenters', + color: '#00ffff', + source: 'Local', + osmDerived: true, + labels: true, + labelMax: 700, + labelGridPx: 138, +}; +const BLUE = rgbaFromCss(DAMS.color).rgba(); +const near = (actual, expected, label = '') => + expected.forEach((value, index) => + assert.ok( + Math.abs(actual[index] - value) < 1e-6, + `${label} ${actual} vs ${expected}`, + ), + ); +const samePosition = (actual, expected, label = '') => + near( + [actual.x, actual.y, actual.z], + [expected.x, expected.y, expected.z], + label, + ); + +// What the engine's ground sets, lines, flights, camera inputs and render +// requests were given. +function watch(engine) { + const sets = new Map(); + const createSet = engine.ground.createSet.bind(engine.ground); + engine.ground.createSet = (options) => { + const set = createSet(options); + const record = { options, pending: [], items: [], commits: 0 }; + const { replace, commit } = set; + set.replace = (items) => { + replace(items); + record.pending = items; + }; + set.commit = () => { + commit(); + record.items = record.pending; + record.commits++; + }; + sets.set(options.id, record); + return set; + }; + const lines = new Map(); + const createLine = engine.lines.createLine.bind(engine.lines); + engine.lines.createLine = (options) => { + const line = createLine(options); + const record = { options, positions: null, positionsSet: 0, shows: [] }; + lines.set(options.id, record); + const { setPositions, show } = line; + line.setPositions = (positions) => { + record.positionsSet++; + record.positions = positions.map((p) => geo.cartesian3.clone(p)); + setPositions(positions); + }; + line.show = (visible) => { + record.shows.push(visible); + show(visible); + }; + return line; + }; + const flights = []; + engine.camera.flyTo = (options) => flights.push(options); + const cameraInputs = []; + const setCameraInputs = engine.input.setCameraInputs.bind(engine.input); + engine.input.setCameraInputs = (enabled) => { + cameraInputs.push(enabled); + setCameraInputs(enabled); + }; + const requests = []; + const request = engine.render.request.bind(engine.render); + engine.render.request = (reason) => { + requests.push(reason); + request(reason); + }; + const liveLines = () => engine.lines.list().map(({ id }) => id); + return { + groundSet(id) { + const listed = engine.ground.list().find((entry) => entry.id === id); + return listed ? sets.get(id) : undefined; + }, + /** A live line: its options, last positions and setPositions count. */ + line: (id) => (liveLines().includes(id) ? lines.get(id) : undefined), + liveLines, + flights, + cameraInputs, + retries: () => + requests.filter((reason) => + String(reason).startsWith('local-ground-retry'), + ).length, + requests, + look(lon, lat, height, orientation) { + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(lon, lat, height), + ...(orientation ? { orientation } : {}), + }); + }, + }; +} + +function fakeHarness() { + const engine = createFakeEngine({ width: 800, height: 600 }); + const watched = watch(engine); + let sampleCalls = 0; + return { + name: 'the fake engine', + engine, + ...watched, + preRender: () => engine.fake.emit('frames.preRender', 0), + moveEnd: () => engine.fake.emit('camera.moveEnd'), + /** The scene under the features (heights in metres). */ + surface({ + supported = false, + sample = () => undefined, + terrain, + tilesLoaded = true, + photoreal = false, + } = {}) { + engine.surface.supported = supported; + engine.fake.script('sampleHeight', (carto) => { + sampleCalls++; + return sample(carto); + }); + engine.fake.script('terrainHeight', () => terrain()); + engine.fake.script('tilesLoaded', () => tilesLoaded()); + engine.fake.maps.photoreal = photoreal ? {} : null; + }, + sampleCalls: () => sampleCalls, + /** A mover set's instance: shown, colour, size, pickId and position. */ + point(id, index) { + const entry = engine.fake.moverSet(id); + if (!entry || index >= entry.store.count) return undefined; + const { store, options } = entry; + return { + shown: store.shows[index] === 1, + color: [...store.colors.slice(index * 4, index * 4 + 4)], + sizePx: options.point.sizePx, + outlinePx: options.point.outlinePx, + depthTest: options.depthTest, + id: store.pickIds[index], + position: { + x: store.positions[index * 3], + y: store.positions[index * 3 + 1], + z: store.positions[index * 3 + 2], + }, + }; + }, + moverIds: () => engine.movers.list().map(({ id }) => id), + click(target) { + const picked = + target?.ground !== undefined + ? { + id: watched.groundSet(target.ground).items[target.instance] + .pickId, + owner: target.ground, + instance: target.instance, + } + : target?.mover !== undefined + ? { + id: engine.fake.moverSet(target.mover).store.pickIds[ + target.instance + ], + owner: target.mover, + instance: target.instance, + } + : target?.line !== undefined + ? { + id: engine.fake.line(target.line).options.pickId, + owner: target.line, + } + : target; + engine.fake.script('pick', picked); + engine.fake.input('click', { position: { x: 100, y: 100 } }); + engine.fake.unscript('pick'); + }, + }; +} + +function cesiumHarness() { + const handlers = createFakeEventHandlers(); + const { engine, viewer, primitive } = createCesiumMoversEngine({ + engineOptions: { + createEventHandler: handlers.create, + scheduleFrame: (run) => run(), + }, + }); + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); + viewer.scene.screenSpaceCameraController = { enableInputs: true }; + // The fake engine's 800 × 600 view, with Cesium's 60° frustum. + Object.assign(viewer.scene.canvas, { + clientWidth: 800, + clientHeight: 600, + width: 800, + height: 600, + }); + viewer.scene.drawingBufferWidth = 800; + viewer.scene.drawingBufferHeight = 600; + viewer.camera.frustum.aspectRatio = 800 / 600; + let native; + viewer.scene.pick = () => native; + viewer.scene.drillPick = () => (native ? [native] : []); + const watched = watch(engine); + const count = (id) => engine.movers.list().find((set) => set.id === id).count; + let sampleCalls = 0; + let photoreal = false; + engine.maps.photorealActive = () => photoreal; + return { + name: 'the Cesium engine', + cesium: true, + engine, + viewer, + ...watched, + preRender: () => viewer.scene.preRender.raiseEvent(viewer.scene), + moveEnd: () => viewer.camera.moveEnd.raiseEvent(), + surface({ + supported = false, + sample = () => undefined, + terrain, + tilesLoaded = true, + photoreal: tiles = false, + } = {}) { + viewer.scene.sampleHeightSupported = supported; + viewer.scene.sampleHeight = (carto) => { + sampleCalls++; + const height = sample(carto); + // A scene whose tiles are not sampleable yet throws. + if (height === undefined) throw new Error('tiles not sampleable yet'); + return height; + }; + photoreal = tiles; + const globe = viewer.scene.globe; + globe.show = !tiles; + globe.getHeight = () => terrain(); + Object.defineProperty(globe, 'tilesLoaded', { + configurable: true, + get: () => tilesLoaded(), + }); + }, + sampleCalls: () => sampleCalls, + point(id, index) { + const collection = primitive(id); + if (!collection || index >= count(id)) return undefined; + const item = collection.get(index); + return { + shown: item.show, + color: [ + item.color.red, + item.color.green, + item.color.blue, + item.color.alpha, + ], + sizePx: item.pixelSize, + outlinePx: item.outlineWidth, + depthTest: + item.disableDepthTestDistance === Number.POSITIVE_INFINITY + ? 'always' + : item.disableDepthTestDistance === 0 + ? 'normal' + : item.disableDepthTestDistance, + id: item.id, + position: item.position, + }; + }, + moverIds: () => engine.movers.list().map(({ id }) => id), + click(target) { + native = + target?.ground !== undefined + ? { id: { gevGroundSet: target.ground, instance: target.instance } } + : target?.mover !== undefined + ? { primitive: primitive(target.mover).get(target.instance) } + : target?.line !== undefined + ? { primitive: primitive(target.line) } + : target; + for (const handler of handlers.handlers) + handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, { + position: new Cesium.Cartesian2(100, 100), + }); + native = undefined; + }, + }; +} + +const HARNESSES = [fakeHarness, cesiumHarness]; + +/** Freezes performance.now so the 450 ms walk gate is under test control. */ +function installFakeClock(t, startMs = 1_000_000) { + const original = performance.now; + let nowMs = startMs; + performance.now = () => nowMs; + t.after(() => { + performance.now = original; + }); + return { + advance(ms) { + nowMs += ms; + }, + }; +} + +function services() { + const calls = []; + const contexts = new Map(); + const selected = []; + return { + calls, + contexts, + selected, + publishes: () => calls.filter(([type]) => type === 'entries'), + services: { + overlayHost: { + setVisible: (...args) => calls.push(['visible', ...args]), + setEntries: (...args) => calls.push(['entries', ...args]), + clearSource: (...args) => calls.push(['clear', ...args]), + }, + registerEntityContext(entity, metadata) { + contexts.set(metadata.id, { entity, ...metadata }); + }, + selectEntityContext: (entity) => selected.push(entity), + clearSelectedEntityContextForLayer: (layerId) => + calls.push(['clear-selection', layerId]), + removeEntityContextsForLayer(layerId) { + for (const [key, record] of contexts) + if (record.layerId === layerId) contexts.delete(key); + }, + showOsmCredit: (target, owner) => calls.push(['credit', target, owner]), + hideOsmCredit: (target, owner) => calls.push(['uncredit', target, owner]), + }, + }; +} + +const ring = (lon, lat, size = 0.01) => [ + [lon, lat], + [lon + size, lat], + [lon + size, lat + size], + [lon, lat], +]; + +/** The session's dataset: features, and records for points and polygons. */ +function dataset(features) { + const records = []; + for (const feature of features) { + const g = feature.geometry; + let lon, lat; + if (g.type === 'Point') [lon, lat] = g.coordinates; + else if (g.type === 'Polygon') { + const center = geo.boundingSphere.fromPoints( + g.coordinates[0].map(([x, y]) => geo.cartesian3.fromDegrees(x, y)), + ).center; + const carto = geo.cartographic.fromCartesian(center); + lon = geo.math.toDegrees(carto.longitude); + lat = geo.math.toDegrees(carto.latitude); + } else continue; + records.push({ + id: feature.id, + lon, + lat, + properties: feature.properties ?? {}, + geometry: g, + }); + } + return { features, records }; +} + +const DAM = { + type: 'Feature', + id: 'real-dam', + properties: { + name: 'Runtime Dam', + tags: { associated_river: 'Test River' }, + }, + geometry: { type: 'Polygon', coordinates: [ring(-97.7, 30.2)] }, +}; +const DAM_LON = -97.695; +const DAM_LAT = 30.2033; + +function fixture(t, makeHarness, { config = DAMS, surface = {} } = {}) { + const harness = makeHarness(); + const host = services(); + harness.surface({ + terrain: () => undefined, + tilesLoaded: () => true, + ...surface, + }); + const display = createInfrastructureDisplay(harness.engine, { + config, + services: host.services, + }); + t.after(() => { + display.destroy(); + harness.engine.destroy(); + }); + const tips = `${config.id}-tips`; + return { + ...harness, + ...host, + display, + tips, + tip: (index = 0) => harness.point(tips, index), + shownCount: () => { + let shown = 0; + for (let index = 0; harness.point(tips, index); index++) + if (harness.point(tips, index).shown) shown++; + return shown; + }, + stemId: (featureId) => `${config.id}-stem-${featureId}`, + /** The dam's base height: 0 until a sample lands. */ + baseHeight(featureId = DAM.id) { + const line = harness.line(`${config.id}-stem-${featureId}`); + return geo.cartographic.fromCartesian(line.positions[0]).height; + }, + }; +} + +/** Each walk arms a timer, the timer asks for a frame, the frame walks. */ +function runArmedRetryChain(f, t, clock, cycles) { + for (let i = 0; i < cycles; i++) { + f.preRender(); + clock.advance(2_100); + t.mock.timers.tick(2_100); + } +} + +/** The camera `southM` south of the dam at `cameraM`, aimed at `groundM`. */ +function aim(f, { groundM, cameraM, southM, away = false }) { + const lat = DAM_LAT - geo.math.toDegrees(southM / 6_371_000); + const pitch = Math.atan2(groundM - cameraM, southM); + f.look(DAM_LON, lat, cameraM, { + heading: away ? Math.PI : 0, + pitch: away ? -pitch : pitch, + roll: 0, + }); +} + +/** Whether a position is off the screen (or behind the camera). */ +function offScreen(engine, position) { + const at = engine.projector.worldToWindow(position); + const { width, height } = engine.host.size(); + return !at || at.x < 0 || at.y < 0 || at.x > width || at.y > height; +} + +for (const makeHarness of HARNESSES) { + const { name } = makeHarness(); + + test(`references are 10 px tips over the scene on 3.5 px stems, pins under Point features (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.look(DAM_LON, DAM_LAT, 20_000); + f.display.show( + dataset([ + DAM, + { + type: 'Feature', + id: 'point-dam', + properties: { name: 'Point Dam' }, + geometry: { type: 'Point', coordinates: [-97.69, 30.21] }, + }, + ]), + ); + f.preRender(); + const tip = f.tip(0); + assert.equal(tip.sizePx, 10); + assert.equal(tip.outlinePx, 2); + assert.equal(tip.depthTest, 'always', 'never hidden by the scene'); + near(tip.color, BLUE); + assert.equal(tip.id, 'local-dams:real-dam'); + assert.equal(f.tip(1).id, 'local-dams:point-dam'); + const stem = f.line(f.stemId('real-dam')); + assert.equal(stem.options.widthPx, 3.5); + assert.deepEqual(stem.options.color, BLUE); + assert.equal(stem.options.pickId, 'local-dams:real-dam'); + assert.equal(stem.options.depthFailAlpha ?? 0, 0, 'hidden by the scene'); + assert.ok(f.line(f.stemId('point-dam'))); + // The pin sits at the base of the Point feature only. + const pin = f.point('local-dams-pins', 0); + assert.equal(f.point('local-dams-pins', 1), undefined); + assert.equal(pin.sizePx, 5); + assert.equal(pin.outlinePx, 0); + assert.equal(pin.depthTest, 'normal'); + near(pin.color, BLUE); + assert.equal(pin.id, 'local-dams:point-dam'); + const base = geo.cartographic.fromCartesian(pin.position); + assert.ok(Math.abs(geo.math.toDegrees(base.longitude) + 97.69) < 1e-9); + assert.ok(Math.abs(base.height) < 0.01, 'on the ellipsoid until sampled'); + }); + + test(`footprints fill at 0.3 without an outline, holes kept; lines are 2 px corridors (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.look(DAM_LON, DAM_LAT, 20_000); + const holed = { + type: 'Feature', + id: 'holed', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [ring(-97.6, 30.2, 0.02), ring(-97.595, 30.205, 0.005)], + }, + }; + const line = { + type: 'Feature', + id: 'weir', + properties: { name: 'Weir' }, + geometry: { + type: 'LineString', + coordinates: [ + [-97.5, 30.2], + [-97.49, 30.21], + ], + }, + }; + f.display.show(dataset([DAM, holed, line])); + const lines = f.groundSet('local-dams-lines'); + assert.equal(lines.options.kind, 'corridor'); + assert.equal(lines.options.pickable, true); + assert.equal(lines.options.order, 1, 'over the footprints'); + assert.deepEqual(lines.items, [ + { + positions: line.geometry.coordinates, + color: BLUE, + widthPx: 2, + pickId: 'local-dams:weir', + }, + ]); + f.preRender(); + const footprints = f.groundSet('local-dams-footprints'); + assert.equal(footprints.options.kind, 'polygon'); + assert.equal(footprints.options.pickable, true); + assert.deepEqual( + footprints.items.map(({ pickId }) => pickId), + ['local-dams:real-dam', 'local-dams:holed'], + ); + const [first, second] = footprints.items; + assert.deepEqual(first.fill, [...BLUE.slice(0, 3), 0.3]); + assert.equal(first.outline, undefined, 'Cesium draws no clamped outline'); + assert.equal(second.rings.length, 2, 'the hole is kept'); + assert.equal(f.liveLines().includes(f.stemId('weir')), false); + assert.equal(f.display.lod().total, 2, 'a line has no reference'); + }); + + test(`records register their context, the OSM credit follows the data (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.display.show(dataset([DAM])); + const context = f.contexts.get('local-dams:real-dam'); + assert.equal(context.layerId, 'local-dams'); + assert.equal(context.layerName, 'Dams'); + assert.equal(context.source, 'USACE'); + assert.equal(context.label, 'Runtime Dam'); + assert.deepEqual(context.properties, DAM.properties); + assert.equal(context.latitude, Number(context.entity.lat.toFixed(6))); + assert.equal(context.longitude, Number(context.entity.lon.toFixed(6))); + assert.deepEqual( + f.calls.filter(([type]) => type === 'credit'), + [['credit', f.engine, 'local-dams']], + ); + f.display.hide(); + assert.equal(f.contexts.size, 0); + assert.ok(f.calls.some((call) => call[0] === 'uncredit')); + assert.ok( + f.calls.some( + ([type, layerId]) => + type === 'clear-selection' && layerId === 'local-dams', + ), + ); + }); + + test(`a layer that is not OSM-derived shows no OSM credit (${name})`, (t) => { + const f = fixture(t, makeHarness, { + config: { ...DAMS, osmDerived: false }, + }); + f.display.show(dataset([DAM])); + assert.equal(f.calls.filter(([type]) => type === 'credit').length, 0); + }); + + test(`parked frames do not republish the same cohort; a move or a re-show does (${name})`, (t) => { + const f = fixture(t, makeHarness); + const clock = installFakeClock(t); + f.look(DAM_LON, DAM_LAT, 100_000); + const data = dataset([DAM]); + f.display.show(data); + f.preRender(); + assert.equal(f.publishes().length, 1); + assert.ok(f.publishes()[0][2].length > 0, 'the cohort is populated'); + assert.deepEqual(f.publishes()[0][3], { + cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT, + collisionCapacity: 96, + moving: false, + }); + for (let i = 0; i < 6; i++) { + clock.advance(1_000); + f.preRender(); + } + assert.equal(f.publishes().length, 1); + f.look(DAM_LON + 0.1, DAM_LAT, 100_000); + f.moveEnd(); + f.preRender(); + assert.equal(f.publishes().length, 2, 'moved tips reach the host'); + f.display.hide(); + assert.ok( + f.calls.some(([type, id]) => type === 'clear' && id === 'local-dams'), + ); + f.display.show(data); + f.preRender(); + assert.equal(f.publishes().length, 3, 'a re-show republishes'); + }); + + test(`the first walk runs on the next frame and the cards follow the visible tips (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.look(DAM_LON, DAM_LAT, 100_000); + f.display.show(dataset([DAM])); + assert.equal(f.publishes().length, 0); + assert.equal(f.display.lod().computed, false); + assert.ok(f.requests.includes('local-dams'), 'asks for that frame'); + f.preRender(); + const [entry] = f.publishes()[0][2]; + assert.equal(entry.id, 'real-dam'); + assert.equal(entry.title, 'Runtime Dam'); + assert.deepEqual(entry.details, ['Test River']); + samePosition(entry.position, f.tip(0).position); + }); + + test(`unchanged moveEnds do not redraw the stem; each real tip change draws once (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.look(DAM_LON, DAM_LAT, 100_000); + f.display.show(dataset([DAM])); + f.preRender(); + const stem = () => f.line(f.stemId('real-dam')); + const drawn = stem().positionsSet; + f.moveEnd(); + f.preRender(); + f.moveEnd(); + f.preRender(); + assert.equal(stem().positionsSet, drawn); + const camera = f.engine.camera.pose().position; + const move = (dx) => + f.engine.camera.setView({ + destination: geo.cartesian3.add( + camera, + geo.cartesian3.create(dx, 0, 0), + geo.cartesian3.create(), + ), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + move(LOCAL_STEM_TIP_EPSILON_M / 10); + f.moveEnd(); + f.preRender(); + assert.equal(stem().positionsSet, drawn, 'sub-epsilon noise is not drawn'); + for (const [index, dx] of [10_000, 20_000, 30_000].entries()) { + move(dx); + f.moveEnd(); + f.preRender(); + assert.equal(stem().positionsSet, drawn + index + 1); + samePosition(stem().positions[1], f.tip(0).position); + } + }); + + for (const [heightM, minStemM, maxStemM] of [ + [500, 45, 90], + [10000, 1100, 1400], + ]) + test(`stems stay proportional at ${heightM} m (${name})`, (t) => { + const f = fixture(t, makeHarness); + const data = dataset([DAM]); + const { lon, lat } = data.records[0]; + f.look(lon, lat, heightM); + f.display.show(data); + f.preRender(); + const [base, tip] = f.line(f.stemId('real-dam')).positions; + const stemM = geo.cartesian3.distance(base, tip); + assert.ok( + stemM >= minStemM && stemM <= maxStemM, + `${stemM.toFixed(1)} m at ${heightM} m`, + ); + }); + + test(`ground sampling waits for the loaded globe before caching a height (${name})`, (t) => { + let loaded = false; + const f = fixture(t, makeHarness, { + surface: { + supported: true, + sample: () => 117, + terrain: () => 117, + tilesLoaded: () => loaded, + }, + }); + const clock = installFakeClock(t); + f.look(DAM_LON, DAM_LAT, 20_000); + f.display.show(dataset([DAM])); + f.preRender(); + assert.equal(f.sampleCalls(), 0, 'streaming terrain is not a final sample'); + assert.ok(Math.abs(f.baseHeight()) < 0.01); + loaded = true; + clock.advance(2_100); + f.preRender(); + assert.equal(f.sampleCalls(), 1, 'the retry samples the settled scene'); + assert.ok(Math.abs(f.baseHeight() - 117) < 0.01); + assert.ok( + Math.abs(geo.cartographic.fromCartesian(f.tip(0).position).height - 117) > + 1, + 'the tip stands above the new base', + ); + }); + + test(`an unseen base is not sampled and arms no retry until it is in view (${name})`, (t) => { + const f = fixture(t, makeHarness, { + surface: { supported: true, sample: () => 117, terrain: () => 117 }, + }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const clock = installFakeClock(t); + // Looking away from the dam. + aim(f, { groundM: 117, cameraM: 20_000, southM: 5_000, away: true }); + f.display.show(dataset([DAM])); + runArmedRetryChain(f, t, clock, 5); + assert.equal(f.sampleCalls(), 0, 'an off-screen base is not sampled'); + assert.equal(f.retries(), 0, 'nor keeps a parked camera rendering'); + // In view but over the horizon: 50 m up, about 67 km away. + f.look(-97.0, DAM_LAT, 50, { + heading: -Math.PI / 2, + pitch: 0, + roll: 0, + }); + f.moveEnd(); + runArmedRetryChain(f, t, clock, 5); + assert.equal(f.sampleCalls(), 0, 'a base over the horizon is not sampled'); + assert.equal(f.retries(), 0); + f.look(DAM_LON, DAM_LAT, 20_000); + f.moveEnd(); + f.preRender(); + assert.equal(f.sampleCalls(), 1, 'sampled on the first walk that sees it'); + assert.ok(Math.abs(f.baseHeight() - 117) < 0.01); + }); + + for (const { label, photoreal, groundM, cameraM, southM, baseOffScreen } of [ + // 2,500 m terrain from 3 km away at a shallow angle: the ellipsoid-height + // base is far below the bottom of the screen. + { + label: 'high ground with loaded terrain', + photoreal: false, + groundM: 2_500, + cameraM: 3_500, + southM: 3_000, + baseOffScreen: true, + }, + { + label: 'high ground under photoreal tiles', + photoreal: true, + groundM: 2_500, + cameraM: 3_500, + southM: 3_000, + baseOffScreen: true, + }, + // A low-geoid coast (about -63 m near Mumbai) from 50 m up: the + // ellipsoidal horizon would hide the ground itself. + { + label: 'ground below the ellipsoid', + photoreal: false, + groundM: -63, + cameraM: 50, + southM: 2_000, + baseOffScreen: false, + }, + ]) + test(`${label} in view is sampled (${name})`, (t) => { + const f = fixture(t, makeHarness, { + surface: { + supported: true, + sample: () => groundM, + terrain: () => (photoreal ? undefined : groundM), + photoreal, + }, + }); + const clock = installFakeClock(t); + const data = dataset([DAM]); + f.display.show(data); + aim(f, { groundM, cameraM, southM }); + const base = geo.cartesian3.fromDegrees( + data.records[0].lon, + data.records[0].lat, + ); + if (baseOffScreen) + assert.ok( + offScreen(f.engine, base), + 'precondition: the ellipsoid-height base is off screen', + ); + clock.advance(2_100); + f.preRender(); + assert.equal(f.sampleCalls(), 1, 'the ground in view is sampled'); + assert.ok(Math.abs(f.baseHeight() - groundM) < 0.01); + }); + + test(`under photoreal tiles, ground behind the camera is not sampled (${name})`, (t) => { + const f = fixture(t, makeHarness, { + surface: { + supported: true, + sample: () => 2_500, + terrain: () => undefined, + photoreal: true, + }, + }); + const clock = installFakeClock(t); + aim(f, { groundM: 2_500, cameraM: 3_500, southM: 3_000, away: true }); + f.display.show(dataset([DAM])); + clock.advance(2_100); + f.preRender(); + assert.equal(f.sampleCalls(), 0); + }); + + test(`settled terrain lifts a sampled base without another sample (${name})`, (t) => { + let terrain = 117; + let loaded = true; + let photoreal = false; + const f = fixture(t, makeHarness, { + surface: { + supported: true, + sample: () => 117, + terrain: () => terrain, + tilesLoaded: () => loaded, + }, + }); + const clock = installFakeClock(t); + f.look(DAM_LON, DAM_LAT, 20_000); + f.display.show(dataset([DAM])); + f.preRender(); + assert.ok(Math.abs(f.baseHeight() - 117) < 0.01); + terrain = 120; + loaded = false; + clock.advance(500); + f.preRender(); + assert.ok(Math.abs(f.baseHeight() - 117) < 0.01, 'waits for the mesh'); + loaded = true; + clock.advance(500); + f.preRender(); + assert.ok(Math.abs(f.baseHeight() - 120) < 0.01, 'follows the floor'); + terrain = 110; + clock.advance(500); + f.preRender(); + assert.ok(Math.abs(f.baseHeight() - 120) < 0.01, 'never lowered'); + terrain = 140; + photoreal = true; + f.surface({ + supported: true, + sample: () => 117, + terrain: () => terrain, + tilesLoaded: () => loaded, + photoreal, + }); + clock.advance(500); + f.preRender(); + assert.ok( + Math.abs(f.baseHeight() - 120) < 0.01, + 'terrain under photoreal tiles is not a floor', + ); + assert.equal(f.sampleCalls(), 1, 'one sample in all'); + }); + + for (const [terrainHeight, sampledHeight, expectedHeight, photoreal] of [ + [117, -2238, 117, false], + [-400, -410, -400, false], + [117, 180, 180, false], + [117, -20, -20, true], + ]) + test(`a sample of ${sampledHeight} over terrain ${terrainHeight} (photoreal ${photoreal}) is ${expectedHeight} (${name})`, (t) => { + const f = fixture(t, makeHarness, { + surface: { + supported: true, + sample: () => sampledHeight, + terrain: () => terrainHeight, + photoreal, + }, + }); + installFakeClock(t); + f.look(DAM_LON, DAM_LAT, 20_000); + f.display.show(dataset([DAM])); + f.preRender(); + assert.ok( + Math.abs(f.baseHeight() - expectedHeight) < 0.01, + `base ${f.baseHeight()}`, + ); + }); + + test(`the stem is out of the scene while its ground is sampled (${name})`, (t) => { + let fails = true; + const f = fixture(t, makeHarness, { + surface: { + supported: true, + sample: () => (fails ? undefined : 50), + terrain: () => 0, + }, + }); + const clock = installFakeClock(t); + f.look(DAM_LON, DAM_LAT, 20_000); + f.display.show(dataset([DAM])); + f.preRender(); + fails = false; + clock.advance(2_100); + f.preRender(); + assert.deepEqual(f.line(f.stemId('real-dam')).shows, [false, true]); + assert.ok(Math.abs(f.baseHeight() - 50) < 0.01); + }); + + test(`a failed ground sample schedules the retry frame the idle governor would not (${name})`, (t) => { + const f = fixture(t, makeHarness, { + surface: { supported: true, terrain: () => 0 }, + }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + f.look(DAM_LON, DAM_LAT, 20_000); + f.display.show(dataset([DAM])); + f.preRender(); + assert.equal(f.retries(), 0, 'deferred by the retry window'); + t.mock.timers.tick(2_100); + assert.ok(f.requests.includes('local-ground-retry:local-dams')); + }); + + test(`a far camera arms no retry (${name})`, (t) => { + const f = fixture(t, makeHarness, { + surface: { supported: true, terrain: () => 0 }, + }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + f.look(DAM_LON, DAM_LAT, 400_000); + f.display.show(dataset([DAM])); + f.preRender(); + t.mock.timers.tick(5_000); + assert.equal(f.retries(), 0); + }); + + test(`hide cancels a pending retry (${name})`, (t) => { + const f = fixture(t, makeHarness, { + surface: { supported: true, terrain: () => 0 }, + }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + f.look(DAM_LON, DAM_LAT, 20_000); + f.display.show(dataset([DAM])); + f.preRender(); + f.display.hide(); + t.mock.timers.tick(5_000); + assert.equal(f.retries(), 0); + }); + + test(`a scene that cannot sample never samples and never arms (${name})`, (t) => { + const f = fixture(t, makeHarness, { + surface: { supported: false, sample: () => 117, terrain: () => 0 }, + }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const clock = installFakeClock(t); + f.look(DAM_LON, DAM_LAT, 20_000); + f.display.show(dataset([DAM])); + f.preRender(); + clock.advance(2_100); + runArmedRetryChain(f, t, clock, 5); + assert.equal(f.retries(), 0); + assert.equal(f.sampleCalls(), 0); + assert.ok(Math.abs(f.baseHeight()) < 1e-6, 'bases stay on the ellipsoid'); + }); + + test(`a sampleable scene keeps trying (${name})`, (t) => { + const f = fixture(t, makeHarness, { + surface: { supported: true, terrain: () => 0 }, + }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const clock = installFakeClock(t); + f.look(DAM_LON, DAM_LAT, 20_000); + f.display.show(dataset([DAM])); + runArmedRetryChain(f, t, clock, 5); + assert.equal(f.sampleCalls(), 5); + }); + + test(`sampling that comes back re-opens a spent budget without camera motion (${name})`, (t) => { + let supported = true; + const f = fixture(t, makeHarness); + const setSurface = () => + f.surface({ supported, terrain: () => 0, tilesLoaded: () => true }); + setSurface(); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const clock = installFakeClock(t); + f.look(DAM_LON, DAM_LAT, 20_000); + f.display.show(dataset([DAM])); + runArmedRetryChain(f, t, clock, GROUND_SAMPLE_MAX_ARMED_RETRIES + 2); + assert.equal(f.retries(), GROUND_SAMPLE_MAX_ARMED_RETRIES, 'budget spent'); + supported = false; + setSurface(); + runArmedRetryChain(f, t, clock, 2); + assert.equal(f.retries(), GROUND_SAMPLE_MAX_ARMED_RETRIES); + supported = true; + setSurface(); + runArmedRetryChain(f, t, clock, 3); + assert.equal(f.retries(), GROUND_SAMPLE_MAX_ARMED_RETRIES + 3); + }); + + test(`a scene that keeps failing gives up after a bounded run of arms (${name})`, (t) => { + const f = fixture(t, makeHarness, { + surface: { supported: true, terrain: () => 0 }, + }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const clock = installFakeClock(t); + f.look(DAM_LON, DAM_LAT, 20_000); + f.display.show(dataset([DAM])); + runArmedRetryChain(f, t, clock, GROUND_SAMPLE_MAX_ARMED_RETRIES); + assert.equal(f.retries(), GROUND_SAMPLE_MAX_ARMED_RETRIES); + runArmedRetryChain(f, t, clock, 10); + assert.equal(f.retries(), GROUND_SAMPLE_MAX_ARMED_RETRIES, 'stops arming'); + }); + + test(`after the cap a camera move still samples and re-opens the budget (${name})`, (t) => { + let height; + const f = fixture(t, makeHarness, { + surface: { supported: true, sample: () => height, terrain: () => 0 }, + }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const clock = installFakeClock(t); + f.look(DAM_LON, DAM_LAT, 20_000); + f.display.show(dataset([DAM])); + runArmedRetryChain(f, t, clock, GROUND_SAMPLE_MAX_ARMED_RETRIES + 3); + assert.equal(f.retries(), GROUND_SAMPLE_MAX_ARMED_RETRIES); + assert.ok(Math.abs(f.baseHeight()) < 1e-6); + height = 210; + f.look(DAM_LON, DAM_LAT, 19_000); + f.moveEnd(); + clock.advance(2_100); + f.preRender(); + assert.ok(Math.abs(f.baseHeight() - 210) < 1e-6, 'the move grounds it'); + runArmedRetryChain(f, t, clock, 5); + assert.equal( + f.retries(), + GROUND_SAMPLE_MAX_ARMED_RETRIES, + 'a grounded record asks for nothing', + ); + }); +} + +// 150 polygons near Austin; the even ones are named (high label priority). +function lodFeatures(count = 150) { + return Array.from({ length: count }, (_, i) => { + const lon = -97.7 + (i % 12) * 0.02; + const lat = 30.2 + Math.floor(i / 12) * 0.02; + return { + type: 'Feature', + id: `dc-${i}`, + properties: + i % 2 === 0 + ? { + name: `Datacenter ${i}`, + tags: { name: `Datacenter ${i}`, operator: 'Example Cloud' }, + } + : { tags: {} }, + geometry: { type: 'Polygon', coordinates: [ring(lon, lat, 0.004)] }, + }; + }); +} + +for (const makeHarness of HARNESSES) { + const { name } = makeHarness(); + const lodFixture = (t, count, heightM) => { + const f = fixture(t, makeHarness, { config: DATACENTERS }); + const clock = installFakeClock(t); + f.look(-97.7, 30.2, heightM); + f.display.show(dataset(lodFeatures(count))); + return { f, clock }; + }; + const shownIds = (f) => { + const ids = []; + for (let index = 0; f.tip(index); index++) + if (f.tip(index).shown) ids.push(f.tip(index).id); + return ids.sort(); + }; + + test(`the LOD caps references at the camera-height budget and widens on zoom (${name})`, (t) => { + const { f, clock } = lodFixture(t, 150, 9_000_000); + f.preRender(); + assert.equal(f.shownCount(), INFRA_LOD_ACTIVE_MIN); + const shown = new Set(shownIds(f)); + for (let i = 0; i < 150; i += 2) + assert.ok(shown.has(`local-datacenters:dc-${i}`), 'named ones first'); + assert.equal( + f.groundSet('local-datacenters-footprints').items.length, + INFRA_LOD_ACTIVE_MIN, + 'footprints follow the LOD', + ); + assert.equal(f.liveLines().length, INFRA_LOD_ACTIVE_MIN, 'one stem each'); + f.look(-97.7, 30.2, 1_000_000); + f.moveEnd(); + clock.advance(500); + f.preRender(); + assert.equal(f.shownCount(), 150); + assert.equal(f.groundSet('local-datacenters-footprints').items.length, 150); + }); + + test(`the LOD selection holds between camera moves (${name})`, (t) => { + const { f, clock } = lodFixture(t, 150, 9_000_000); + f.preRender(); + const first = shownIds(f); + const commits = f.groundSet('local-datacenters-footprints').commits; + clock.advance(500); + f.preRender(); + assert.deepEqual(shownIds(f), first); + assert.equal( + f.groundSet('local-datacenters-footprints').commits, + commits, + 'no footprint rebuild', + ); + }); + + test(`lod() reports the active/total split and the band budget (${name})`, (t) => { + const { f, clock } = lodFixture(t, 150, 9_000_000); + assert.deepEqual(f.display.lod(), { + total: 150, + active: 0, + budgetLimit: 0, + computed: false, + }); + f.preRender(); + assert.deepEqual(f.display.lod(), { + total: 150, + active: INFRA_LOD_ACTIVE_MIN, + budgetLimit: INFRA_LOD_ACTIVE_MIN, + computed: true, + }); + f.look(-97.7, 30.2, 60_000); + f.moveEnd(); + clock.advance(500); + f.preRender(); + assert.equal(f.display.lod().active, 150); + assert.ok(f.display.lod().budgetLimit >= 150); + }); + + test(`hide releases every reference; a re-show selects afresh (${name})`, (t) => { + const { f } = lodFixture(t, 40, 9_000_000); + const data = dataset(lodFeatures(40)); + f.display.hide(); + f.display.show(data); + f.preRender(); + assert.equal(f.shownCount(), 40); + f.display.hide(); + assert.deepEqual(f.moverIds(), []); + assert.deepEqual(f.liveLines(), []); + assert.equal(f.engine.ground.list().length, 0); + assert.deepEqual(f.display.lod(), { + total: 0, + active: 0, + budgetLimit: 0, + computed: false, + }); + f.display.show(data); + f.preRender(); + assert.equal(f.shownCount(), 40); + }); + + test(`the LOD re-selects during continuous motion without a moveEnd (${name})`, (t) => { + const { f, clock } = lodFixture(t, 150, 9_000_000); + f.preRender(); + assert.equal(f.display.lod().budgetLimit, INFRA_LOD_ACTIVE_MIN); + f.look(-97.7, 30.2, 60_000); + clock.advance(500); + f.preRender(); + assert.equal( + f.display.lod().budgetLimit, + INFRA_LOD_ACTIVE_MIN, + 'the probe window rate-limits it', + ); + clock.advance(600); + f.preRender(); + assert.equal(f.display.lod().budgetLimit, INFRA_LOD_ACTIVE_MAX); + assert.equal(f.display.lod().active, 150); + assert.equal(f.shownCount(), 150); + const parked = f.display.lod(); + clock.advance(2_000); + f.preRender(); + assert.deepEqual( + f.display.lod(), + parked, + 'a parked camera never re-selects', + ); + }); + + test(`a reference behind the horizon hides with its stem and card (${name})`, (t) => { + const f = fixture(t, makeHarness); + const clock = installFakeClock(t); + f.look(DAM_LON, DAM_LAT, 100_000); + f.display.show(dataset([DAM])); + f.preRender(); + assert.equal(f.tip(0).shown, true); + assert.equal(f.contexts.get('local-dams:real-dam').entity.show, true); + f.look(DAM_LON + 180, -DAM_LAT, 100_000); + clock.advance(500); + f.preRender(); + assert.equal(f.tip(0).shown, false); + assert.equal(f.liveLines().length, 0); + assert.equal( + f.contexts.get('local-dams:real-dam').entity.show, + false, + 'its context is inactive, as a hidden entity', + ); + assert.deepEqual(f.publishes().at(-1)[2], []); + }); + + test(`a click on a tip, stem, pin or footprint selects and flies to the feature (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.look(DAM_LON, DAM_LAT, 20_000); + const point = { + type: 'Feature', + id: 'point-dam', + properties: { name: 'Point Dam' }, + geometry: { type: 'Point', coordinates: [-97.69, 30.21] }, + }; + const data = dataset([DAM, point]); + f.display.show(data); + f.preRender(); + const flight = (lon, lat) => { + const last = f.flights.at(-1); + const carto = geo.cartographic.fromCartesian(last.destination); + assert.ok(Math.abs(geo.math.toDegrees(carto.longitude) - lon) < 1e-9); + assert.ok(Math.abs(geo.math.toDegrees(carto.latitude) - lat) < 1e-9); + assert.ok(Math.abs(carto.height - 5000) < 1e-6); + assert.equal(last.duration, 1.5); + }; + const [dam, pointRecord] = data.records; + f.click({ mover: f.tips, instance: 0 }); + assert.equal(f.selected.at(-1).id, 'real-dam'); + flight(dam.lon, dam.lat); + assert.deepEqual(f.cameraInputs, [false], 'inputs off for the flight'); + f.flights.at(-1).complete(); + assert.deepEqual(f.cameraInputs, [false, true]); + f.click({ mover: 'local-dams-pins', instance: 0 }); + assert.equal(f.selected.at(-1).id, 'point-dam'); + flight(pointRecord.lon, pointRecord.lat); + f.flights.at(-1).cancel(); + assert.equal(f.cameraInputs.at(-1), true); + f.click({ line: f.stemId('point-dam') }); + assert.equal(f.selected.length, 3); + f.click({ ground: 'local-dams-footprints', instance: 0 }); + assert.equal(f.selected.at(-1).id, 'real-dam'); + assert.equal(f.flights.length, 4); + }); + + test(`a click on a line flies to its first vertex without selecting (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.display.show( + dataset([ + { + type: 'Feature', + id: 'weir', + properties: {}, + geometry: { + type: 'LineString', + coordinates: [ + [-97.5, 30.2], + [-97.49, 30.21], + ], + }, + }, + ]), + ); + f.click({ ground: 'local-dams-lines', instance: 0 }); + assert.equal(f.selected.length, 0); + const carto = geo.cartographic.fromCartesian(f.flights[0].destination); + assert.ok(Math.abs(geo.math.toDegrees(carto.longitude) + 97.5) < 1e-9); + }); + + test(`other picks, a claimed pointer and a hidden display ignore the click (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.look(DAM_LON, DAM_LAT, 20_000); + f.display.show(dataset([DAM])); + f.preRender(); + f.click({ id: 'something-else' }); + f.click(undefined); + const lease = claimPointer('draw'); + f.click({ mover: f.tips, instance: 0 }); + releasePointer(lease); + assert.equal(f.flights.length, 0); + assert.equal(f.selected.length, 0); + f.display.hide(); + f.click({ id: 'local-dams:real-dam' }); + assert.equal(f.flights.length, 0); + }); + + test(`select() restores a feature's selection; an unknown one is refused (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.display.show(dataset([DAM])); + assert.equal(f.display.select('real-dam'), true); + assert.equal(f.selected[0].id, 'real-dam'); + assert.equal(f.display.select('nope'), false); + }); + + test(`destroy releases everything and the display stays inert (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.look(DAM_LON, DAM_LAT, 20_000); + const data = dataset([DAM]); + f.display.show(data); + f.preRender(); + f.display.destroy(); + assert.deepEqual(f.moverIds(), []); + assert.deepEqual(f.liveLines(), []); + assert.equal(f.engine.ground.list().length, 0); + assert.equal(f.contexts.size, 0); + const calls = f.calls.length; + f.display.show(data); + f.preRender(); + f.moveEnd(); + assert.equal(f.calls.length, calls, 'no late host work'); + assert.deepEqual(f.moverIds(), []); + }); +} diff --git a/src/layers/infrastructure/render/navara.js b/src/layers/infrastructure/render/navara.js deleted file mode 100644 index c3caf6181..000000000 --- a/src/layers/infrastructure/render/navara.js +++ /dev/null @@ -1,358 +0,0 @@ -import * as geo from '../../../geo/index.js'; -import { - selectInfraLod, - applyInfraEvictionGrace, - INFRA_LOD_ACTIVE_MAX, -} from '../../../data/localGeojsonLod.js'; -import { - createLocalInfrastructureOverlayEntry, - selectLocalInfrastructureOverlayCohort, - createLocalInfrastructureOverlayPublisher, - LOCAL_OVERLAY_COHORT_LIMIT, - labelPriorityFromProperties, - localInfrastructureOverlayCopy, -} from '../../../data/localGeojsonCore.js'; -import { createNavaraPolygonDisplay } from '../../shared/render/navaraPolygons.js'; -import { bindGroundShapePicks } from '../../shared/render/navaraGroundPicks.js'; -import { distanceToSegment } from '../../../globe/cpuPicks.js'; -import { rgbaFromCss } from '../../shared/color.js'; -import { isPointerFree } from '../../../data/inputOwnership.js'; -import { createStemBatch } from '../../shared/render/navaraStems.js'; - -export function createInfrastructureNavaraRenderer( - engine, - native, - config, - services, -) { - const { id, name, source, color, labelMax, labelGridPx } = config; - const rgba = rgbaFromCss(color).rgba(); - const publisher = createLocalInfrastructureOverlayPublisher({ - sourceId: id, - host: services.overlayHost, - }); - const display = createNavaraPolygonDisplay(engine, native.view, (tint) => ({ - polyline: { color: tint, width: 2, clampToGround: true }, - polygon: { - color: tint, - opacity: 0.3, - transparent: true, - clampToGround: true, - outline: true, - outlineColor: tint, - outlineWidth: 2, - }, - })); - let dataset = null, - records = new Map(), - active = new Set(), - grace = new Map(), - shown = [], - budget = 0; - let lastWalk = -Infinity, - signature = '', - topology = '', - points = null, - stems = null, - unbindShapes = null; - let retryTimer = null; - const removers = []; - const wake = () => engine.render.request(id); - const key = (fid) => id + ':' + fid; - function footprints() { - const features = [], - shapes = []; - for (const feature of dataset.features) { - const g = feature.geometry; - if ( - g.type !== 'LineString' && - (g.type !== 'Polygon' || !active.has(feature.id)) - ) - continue; - features.push(feature); - shapes.push({ - id: key(feature.id), - geometry: - g.type === 'LineString' - ? { type: 'line', positions: g.coordinates } - : { - type: 'polygon', - outer: g.coordinates[0], - holes: g.coordinates.slice(1), - }, - style: { lineWidth: 2 }, - }); - } - display.replace(features.length ? new Map([[color, features]]) : new Map()); - unbindShapes?.(); - unbindShapes = bindGroundShapePicks(engine, id, shapes); - } - function sweep(force = false) { - if (!dataset) return; - const now = performance.now(); - if (!force && now - lastWalk < 450) return; - lastWalk = now; - const pose = engine.camera.pose(), - size = engine.host.size(); - const sig = [ - pose.position.x, - pose.position.y, - pose.position.z, - pose.heading, - pose.pitch, - pose.roll, - pose.fovy, - size.width, - size.height, - ].join('/'); - const sampling = shown.some( - (r) => - !r.grounded && - r.tries < 30 && - now - r.lastSample >= 2000 && - r.distance < 75000, - ); - if (!force && signature === sig && !sampling) return; - if (force || signature !== sig) { - const candidates = []; - for (const r of records.values()) { - r.distance = geo.cartesian3.distance(pose.position, r.base); - candidates.push({ - id: r.id, - priority: r.priority, - distanceM: r.distance, - inView: engine.projector.horizonVisible(r.base), - }); - } - const result = selectInfraLod(candidates, { - cameraHeightM: pose.cartographic.height, - incumbentIds: active, - }); - budget = result.budget.activeLimit; - const next = applyInfraEvictionGrace({ - selectedIds: result.activeIds, - builtIds: [...active], - graceState: grace, - nowMs: now, - activeLimit: budget, - }); - active = new Set(next.keepIds); - grace = next.graceState; - signature = sig; - const nextTopology = next.keepIds.slice().sort().join('/'); - if (force || topology !== nextTopology) { - topology = nextTopology; - footprints(); - } - } - shown = []; - for (const fid of active) { - const r = records.get(fid); - if (!engine.projector.horizonVisible(r.base)) continue; - const carto = r.carto; - if ( - r.distance < 75000 && - now - r.lastSample >= 2000 && - (force || r.tries < 30) - ) { - const terrain = engine.surface.terrainHeight(carto); - const sampled = engine.surface.supported - ? engine.surface.sampleHeight(carto, [points]) - : undefined; - const floor = Number.isFinite(sampled) - ? Math.max(sampled, terrain ?? -Infinity) - : terrain; - r.lastSample = now; - r.tries++; - if (Number.isFinite(floor) && Math.abs(floor) <= 9000) { - r.floor = floor; - r.grounded = Number.isFinite(sampled) || !engine.surface.supported; - geo.cartesian3.fromRadians( - carto.longitude, - carto.latitude, - floor, - undefined, - r.base, - ); - } - } - const height = - r.floor + - (Math.max(1, r.distance) * 2 * Math.tan(pose.fovy / 2) * 65) / - Math.max(1, size.height); - geo.cartesian3.fromRadians( - carto.longitude, - carto.latitude, - height, - undefined, - r.tip, - ); - points.set(shown.length, { - position: r.tip, - color: rgba, - show: true, - pickId: key(fid), - }); - shown.push(r); - } - points.setCount(shown.length); - points.commit(); - stems.set(shown, pose.position); - publisher.publish( - selectLocalInfrastructureOverlayCohort(shown, { - maxEntries: labelMax, - gridPx: labelGridPx, - width: size.width, - height: size.height, - cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT, - project: (r) => engine.projector.worldToWindow(r.tip, {}), - }), - ); - if ( - !retryTimer && - engine.surface.supported && - shown.some((r) => !r.grounded && r.distance < 75000 && r.tries < 30) - ) { - retryTimer = setTimeout(() => { - retryTimer = null; - wake(); - }, 2050); - } - wake(); - } - function select(fid) { - const row = records.get(fid); - if (!row) return false; - services.selectEntityContext(row); - return true; - } - function hide() { - clearTimeout(retryTimer); - retryTimer = null; - while (removers.length) removers.pop()(); - unbindShapes?.(); - unbindShapes = null; - publisher.hide(); - display.destroy(); - points?.destroy(); - stems?.destroy(); - points = stems = dataset = null; - records.clear(); - active.clear(); - grace.clear(); - shown = []; - budget = 0; - signature = topology = ''; - services.removeEntityContextsForLayer(id); - services.hideOsmCredit(engine, id); - } - return { - show(next) { - if (dataset === next) return; - dataset = next; - points = engine.movers.createSet({ - id, - kind: 'point', - capacity: INFRA_LOD_ACTIVE_MAX, - pickable: true, - depthTest: 'always', - point: { sizePx: 10, outlinePx: 2, outlineColor: [0, 0, 0, 1] }, - }); - stems = createStemBatch(native, color, INFRA_LOD_ACTIVE_MAX); - for (const input of next.records) { - const carto = geo.cartographic.fromDegrees(input.lon, input.lat); - const base = geo.cartesian3.fromDegrees(input.lon, input.lat), - tip = geo.cartesian3.clone(base); - const priority = labelPriorityFromProperties(input.properties, id); - const row = { - ...input, - carto, - base, - tip, - priority, - floor: 0, - tries: 0, - lastSample: -Infinity, - grounded: false, - }; - row.entry = createLocalInfrastructureOverlayEntry({ - id: row.id, - layerId: id, - position: tip, - properties: row.properties, - priority, - accent: color, - }); - records.set(row.id, row); - services.registerEntityContext(row, { - id: key(row.id), - layerId: id, - layerName: name, - source, - label: localInfrastructureOverlayCopy(row.properties, id).title, - properties: row.properties, - latitude: row.lat, - longitude: row.lon, - }); - } - display.setVisible(true); - publisher.show(); - if (next.features.length) services.showOsmCredit(engine, id); - removers.push( - engine.frames.preRender.addEventListener(() => sweep()), - engine.camera.moveEnd.addEventListener(() => sweep(true)), - ); - removers.push( - engine.picker.registerMeshPicker((at) => { - const hits = []; - for (const row of shown) { - const a = engine.projector.worldToWindow(row.base, {}), - b = engine.projector.worldToWindow(row.tip, {}); - if (a && b && distanceToSegment(at, a, b) <= 2.5) - hits.push({ - onTop: 0, - order: 0, - depth: row.distance, - result: { id: key(row.id), owner: id }, - }); - } - return hits; - }), - ); - removers.push( - engine.input.on( - 'click', - (event) => { - if (!isPointerFree() || event.picked?.owner !== id) return; - const fid = String(event.picked.id).slice(id.length + 1); - const row = records.get(fid); - const feature = row || next.features.find((f) => f.id === fid); - if (!feature) return; - if (row) select(fid); - const [lon, lat] = row - ? [row.lon, row.lat] - : feature.geometry.coordinates[0]; - engine.camera.flyTo({ - destination: geo.cartesian3.fromDegrees(lon, lat, 5000), - duration: 1.5, - }); - }, - { pick: true }, - ), - ); - sweep(true); - }, - select, - hide, - destroy() { - hide(); - publisher.destroy(); - }, - lod: () => ({ - total: records.size, - active: active.size, - budgetLimit: budget, - computed: Boolean(dataset), - }), - }; -} diff --git a/src/layers/infrastructure/session.test.mjs b/src/layers/infrastructure/session.test.mjs index a7d1be453..82fd60c83 100644 --- a/src/layers/infrastructure/session.test.mjs +++ b/src/layers/infrastructure/session.test.mjs @@ -1,6 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createInfrastructureSessionLayer } from './index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { localGeoJsonServices } from '../../app/localGeojsonServices.js'; +import { + getContextStore, + selectEntityContext, +} from '../../data/contextStore.js'; +import { layerFeedState } from '../../data/feedState.js'; test('a bundled file delivered between planets keeps multipart IDs and is read only once', async (t) => { let deliver, @@ -106,3 +113,278 @@ test('a released scene stops waiting for a stalled dataset load, which stays in assert.equal(captures.length, 0); assert.equal(layer.getAnalystRecords().length, 1); }); + +// The session layer with the contract display on the fake engine and the +// application's context store: dataset failures, analyst records, and +// the load, toggle and teardown lifecycle. + +const dam = JSON.stringify({ + type: 'Feature', + id: 'dam', + properties: { + name: 'Runtime Dam', + output: '12 MW', + tags: { associated_river: 'Test River', operator: 'Test Hydro' }, + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-97.7, 30.2], + [-97.69, 30.2], + [-97.69, 30.21], + [-97.7, 30.2], + ], + ], + }, +}); +const serve = (text = dam) => ({ + ok: true, + status: 200, + text: async () => text, +}); +const deferred = () => { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +}; + +function contractHarness(t) { + const previousWindow = globalThis.window; + globalThis.window = { dispatchEvent() {} }; + const engine = createFakeEngine(); + const create = () => + createInfrastructureSessionLayer( + { + id: 'local-dams', + name: 'Dams', + color: '#0088ff', + url: '/dams.geojsonl', + osmDerived: true, + }, + { + ...localGeoJsonServices, + overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, + }, + ); + const layer = create(); + layer.init(null, { engine }); + t.after(() => { + layer.destroy(); + engine.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + }); + return { + engine, + layer, + create, + contexts: () => getContextStore().entities.size, + drawn: () => + engine.movers.list().length + + engine.lines.list().length + + engine.ground.list().length, + listeners: () => engine.fake.callsOf('input.on').length, + }; +} + +test('a missing dataset reports UNAVAILABLE instead of a silent empty layer', async (t) => { + const env = contractHarness(t); + t.mock.method(globalThis, 'fetch', async () => ({ + ok: false, + status: 404, + text: async () => '', + })); + await env.layer.enable(); + const stats = env.layer.getStats(); + assert.equal(stats.count, 0); + assert.equal(stats.lastUpdate, null); + assert.equal(stats.error, 'dataset unavailable (HTTP 404)'); + assert.equal(layerFeedState(stats), 'unavailable'); + assert.equal(env.drawn(), 0); +}); + +test('a corrupt dataset line reports malformed rather than parsing into nothing', async (t) => { + const env = contractHarness(t); + t.mock.method(globalThis, 'fetch', async () => serve('{"type":"Feature"\n')); + await env.layer.enable(); + const stats = env.layer.getStats(); + assert.equal(stats.error, 'dataset is malformed'); + assert.equal(layerFeedState(stats), 'unavailable'); +}); + +test('a failed load is retried by the next enable and then clears its error', async (t) => { + const env = contractHarness(t); + let fail = true; + t.mock.method(globalThis, 'fetch', async () => + fail ? { ok: false, status: 500, text: async () => '' } : serve(), + ); + await env.layer.enable(); + assert.equal(env.layer.getStats().error, 'dataset unavailable (HTTP 500)'); + fail = false; + env.layer.disable(); + await env.layer.enable(); + const stats = env.layer.getStats(); + assert.equal(stats.error, null); + assert.equal(stats.count, 1); + assert.ok(Number.isFinite(stats.lastUpdate)); + assert.equal(layerFeedState(stats), 'nominal'); + assert.equal(env.engine.ground.list().length, 2, 'one draw, not two'); +}); + +test('a loaded dataset is distinguishable from a dead one', async (t) => { + const env = contractHarness(t); + t.mock.method(globalThis, 'fetch', async () => serve()); + await env.layer.enable(); + const stats = env.layer.getStats(); + assert.equal(stats.error, null); + assert.equal(stats.count, 1); + assert.ok(Number.isFinite(stats.lastUpdate), 'a load timestamps itself'); + assert.equal(layerFeedState(stats), 'nominal'); +}); + +test('analyst records snapshot the enabled layer; disable and destroy return []', async (t) => { + const env = contractHarness(t); + t.mock.method(globalThis, 'fetch', async () => serve()); + assert.deepEqual(env.layer.getAnalystRecords(), []); + await env.layer.enable(); + const rows = env.layer.getAnalystRecords(); + assert.equal(rows.length, 1); + assert.equal(rows[0].id, 'Runtime Dam'); + assert.equal(rows[0].name, 'Runtime Dam'); + assert.equal(rows[0].operator, 'Test Hydro'); + assert.equal(rows[0].river, 'Test River'); + assert.equal(rows[0].output, '12 MW'); + assert.ok(Number.isFinite(rows[0].lat) && Number.isFinite(rows[0].lon)); + assert.deepEqual(JSON.parse(JSON.stringify(rows[0])), rows[0]); + assert.equal(env.layer.getAnalystRecords(0).length, 1, 'at least one'); + env.layer.disable(); + assert.deepEqual(env.layer.getAnalystRecords(), []); + await env.layer.enable(); + env.layer.destroy(); + assert.deepEqual(env.layer.getAnalystRecords(), []); +}); + +test('destroy removes context records and permits a fresh replacement', async (t) => { + const env = contractHarness(t); + t.mock.method(globalThis, 'fetch', async () => serve()); + await env.layer.enable(); + const oldEntity = [...getContextStore().entities.values()][0].entity; + assert.equal(env.contexts(), 1); + env.layer.disable(); + assert.equal(env.contexts(), 0, 'disable releases its records'); + assert.equal(env.drawn(), 0, 'and what it drew'); + env.layer.destroy(); + assert.equal(env.contexts(), 0); + assert.equal(env.drawn(), 0); + const replacement = env.create(); + replacement.init(null, { engine: env.engine }); + await replacement.enable(); + assert.equal(env.contexts(), 1); + assert.notEqual( + [...getContextStore().entities.values()][0].entity, + oldEntity, + ); + replacement.destroy(); + await env.layer.enable(); + assert.equal(env.contexts(), 0, 'a destroyed layer stays inert'); + assert.equal(env.drawn(), 0); +}); + +for (const phase of ['fetch', 'text']) { + test(`destroy during ${phase} leaves no late objects or context records`, async (t) => { + const env = contractHarness(t); + const entered = deferred(); + const release = deferred(); + const pause = async (value) => { + entered.resolve(); + await release.promise; + return value; + }; + let signal; + t.mock.method(globalThis, 'fetch', async (_url, options) => { + signal = options.signal; + if (phase === 'fetch') return pause(serve()); + return { ok: true, text: () => pause(dam) }; + }); + const loading = env.layer.enable(); + await entered.promise; + env.layer.destroy(); + assert.equal(signal.aborted, true); + release.resolve(); + await loading; + assert.equal(env.drawn(), 0); + assert.equal(env.contexts(), 0); + assert.deepEqual(env.layer.getStats(), { + count: 0, + lastUpdate: null, + error: null, + }); + }); +} + +test('concurrent enable and disable/re-enable share one pending dataset load', async (t) => { + const env = contractHarness(t); + const release = deferred(); + const fetchMock = t.mock.method(globalThis, 'fetch', async () => { + await release.promise; + return serve(); + }); + const first = env.layer.enable(); + env.layer.disable(); + const second = env.layer.enable(); + release.resolve(); + await Promise.all([first, second]); + assert.equal(fetchMock.mock.callCount(), 1); + assert.equal(env.engine.ground.list().length, 2, 'drawn once'); + assert.equal(env.contexts(), 1); +}); + +test('disable during loading draws nothing; re-enable draws without refetching', async (t) => { + const env = contractHarness(t); + const release = deferred(); + const fetchMock = t.mock.method(globalThis, 'fetch', async () => { + await release.promise; + return serve(); + }); + const loading = env.layer.enable(); + env.layer.disable(); + release.resolve(); + await loading; + assert.equal(env.drawn(), 0, 'a load that ends after disable draws nothing'); + assert.equal(env.contexts(), 0); + await env.layer.enable(); + assert.ok(env.drawn() > 0); + assert.equal(fetchMock.mock.callCount(), 1, 'the dataset is kept'); +}); + +test('a toggle-off frees the drawing and a toggle-on rebuilds it from the kept dataset', async (t) => { + const env = contractHarness(t); + const fetchMock = t.mock.method(globalThis, 'fetch', async () => serve()); + await env.layer.enable(); + assert.equal(env.layer.getLodDiagnostics().total, 1); + env.layer.disable(); + assert.equal(env.drawn(), 0, 'nothing stays drawn while the layer is off'); + assert.equal(env.contexts(), 0); + assert.equal(env.layer.getLodDiagnostics().total, 0); + assert.equal(env.layer.getStats().count, 1, 'the row keeps the dataset size'); + await env.layer.enable(); + assert.ok(env.drawn() > 0); + assert.equal(env.contexts(), 1); + assert.equal(fetchMock.mock.callCount(), 1, 'no second fetch'); + assert.equal(env.layer.getLodDiagnostics().total, 1); +}); + +test('a scene switch restores the selected feature on the next display', async (t) => { + const env = contractHarness(t); + t.mock.method(globalThis, 'fetch', async () => serve()); + await env.layer.enable(); + const record = [...getContextStore().entities.values()][0]; + selectEntityContext(record.entity); + env.layer.detachRenderer(); + assert.equal(getContextStore().selectedEntityId, null); + await env.layer.attachRenderer(null, { engine: env.engine }); + assert.equal(getContextStore().selectedEntityId, 'local-dams:dam'); +}); diff --git a/src/layers/installations/lifecycle.test.mjs b/src/layers/installations/lifecycle.test.mjs index 772e6659a..0045bc354 100644 --- a/src/layers/installations/lifecycle.test.mjs +++ b/src/layers/installations/lifecycle.test.mjs @@ -1,8 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as credits from '../../data/dataCredits.js'; -import { createLifecycle } from './render/cesium/lifecycle.js'; import { LAYER_ID } from './policy.js'; +import { OUTLINE_SET_ID } from './render/display.js'; +import { createDisplayHarness } from './render/displayHarness.mjs'; +import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs'; for (const source of [ { name: 'OpenStreetMap', id: 'tile:base' }, @@ -11,46 +13,20 @@ for (const source of [ null, ]) { test(`enable restores cached installation credits before a refresh: ${source?.id || 'empty'}`, () => { - const visibleCredits = new Set(); - const noop = () => {}; - const viewer = { - camera: {}, - creditDisplay: { - addStaticCredit: (credit) => visibleCredits.add(credit), - removeStaticCredit: (credit) => visibleCredits.delete(credit), - }, - }; - const state = { - viewer, - records: source ? [{ id: 'base', sources: [source] }] : [], - recordById: new Map(), - dataSource: { show: false }, - }; - let markersVisible = false; - const parts = { - namedMarkers: { - enable: () => { - markersVisible = true; - }, - sync: noop, - hide: () => { - markersVisible = false; - }, - }, - rendering: {}, - viewport: { clearUnavailableRetry: noop }, - ingestion: { setInstallationStatus: noop }, - }; - const lifecycle = createLifecycle({ - state, - services: { - credits, - picking: { registerPickOwner: noop, unregisterPickOwner: noop }, - context: { clearSelectedEntityContextForLayer: noop }, - }, - parts, - source: {}, - }).methods; + const h = createDisplayHarness(createFakeGroundHarness); + h.services.credits = credits; + const { engine } = h; + const visibleCredits = () => engine.fake.credits.length; + h.setRecords( + source + ? [{ id: 'base', latitude: 0, longitude: 0, sources: [source] }] + : [], + ); + h.state.recordById = new Map(); + const lifecycle = h.parts.lifecycle.methods; + lifecycle.init(); + const outlinesShown = () => + [0, 1].every((n) => h.set(`${OUTLINE_SET_ID}-${n}`).shown); const expected = source?.name === 'OpenStreetMap' ? source.id.startsWith('tile:') @@ -60,23 +36,24 @@ for (const source of [ try { for (let cycle = 0; cycle < 2; cycle++) { lifecycle.enable(); - assert.equal(state.dataSource.show, true); - assert.equal(markersVisible, true); + assert.equal(outlinesShown(), true); + assert.equal(h.labels.visible, true); // No renderRecords or successful fetch occurs between toggles. - assert.equal(visibleCredits.size, expected); + assert.equal(visibleCredits(), expected); lifecycle.disable(); - assert.equal(state.dataSource.show, false); - assert.equal(markersVisible, false); - assert.equal(visibleCredits.size, 0); + assert.equal(outlinesShown(), false); + assert.equal(h.labels.visible, false); + assert.equal(visibleCredits(), 0); } - credits.showOsmCredit(viewer, 'another-layer'); + credits.showOsmCredit(engine, 'another-layer'); lifecycle.enable(); lifecycle.disable(); - assert.equal(visibleCredits.size, 1, 'another owner retains its credit'); - assert.equal(credits.hideOsmCredit(viewer, LAYER_ID), false); + assert.equal(visibleCredits(), 1, 'another owner retains its credit'); + assert.equal(credits.hideOsmCredit(engine, LAYER_ID), false); } finally { lifecycle.disable(); - credits.hideOsmCredit(viewer, 'another-layer'); + credits.hideOsmCredit(engine, 'another-layer'); + h.destroy(); } }); } diff --git a/src/layers/installations/namedMarkers.test.mjs b/src/layers/installations/namedMarkers.test.mjs index 3eaa1e6e1..9d9e4ddc7 100644 --- a/src/layers/installations/namedMarkers.test.mjs +++ b/src/layers/installations/namedMarkers.test.mjs @@ -1,10 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import * as C from 'cesium'; -import { createNamedMarkers } from './render/cesium/namedMarkers.js'; -import { createLifecycle } from './render/cesium/lifecycle.js'; import { createLocalInfrastructureOverlayEntry } from '../../data/localGeojsonCore.js'; +import { createDisplayHarness } from './render/displayHarness.mjs'; +import { GROUND_HARNESSES } from '../../testSupport/groundDisplayEngines.mjs'; +// The named-area markers on the fake engine: its camera events, frames and +// window projection (scripted per test) drive the discs. function setup(t) { let labels = [], options, publications = 0, visible = false; const overlayHost = { @@ -17,25 +18,38 @@ function setup(t) { }; let x = -100; const projections = new Set(); - t.mock.method(C.SceneTransforms, 'worldToWindowCoordinates', (_scene, _point, scratch) => { projections.add(scratch); scratch.x = x; scratch.y = 300; return scratch; }); - const camera = { changed: new C.Event(), moveEnd: new C.Event(), positionWC: C.Cartesian3.fromDegrees(0, 0, 1e6), viewMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY) }; - const state = { viewer: { camera, dataSources: { add() {}, remove() {} }, scene: { canvas: { clientWidth: 1280, clientHeight: 800 }, preRender: new C.Event(), primitives: { add: (p) => p, remove() {} } } }, enabled: true, contextAnchor: { latitude: 0, longitude: 0 } }; - const services = { render: { governorRequestRender() {} }, picking: { registerPickOwner() {}, unregisterPickOwner() {} }, context: { clearSelectedEntityContextForLayer() {} } }; - let loads = 0; - const parts = { model: { colorFor: () => C.Color.ORANGE }, rendering: { installationSurfaceHeightM: () => 0, clearRendered() {} }, viewport: { scheduleLoad: () => loads++, clearUnavailableRetry() {} }, selection: { installInteraction() {} }, ingestion: { setInstallationStatus() {} } }; - const ctx = { state, services, parts, source: {}, overlayHost }; - parts.namedMarkers = createNamedMarkers(ctx); - const lifecycle = createLifecycle(ctx).methods; - lifecycle.init(state.viewer); lifecycle.enable(); + const h = createDisplayHarness(GROUND_HARNESSES[0], { overlayHost }); + const { engine } = h; + engine.fake.script('worldToWindow', (_point, scratch) => { projections.add(scratch); scratch.x = x; scratch.y = 300; return scratch; }); + engine.fake.setSize(1280, 800); + const base = { + changed: engine.camera.changed.numberOfListeners, + moveEnd: engine.camera.moveEnd.numberOfListeners, + preRender: engine.frames.preRender.numberOfListeners, + }; + h.state.contextAnchor = { latitude: 0, longitude: 0 }; + h.start(); + let height = 1e7; const records = [1, 2].map((n) => ({ id: `osm:military:w${n}`, name: `Site ${n}`, namedArea: true, areaM2: 100 / n, latitude: 0, longitude: 0, pointOnly: true })); - return { state, camera, get labels() { return labels; }, get options() { return options; }, get visible() { return visible; }, projections, records, markers: parts.namedMarkers, lifecycle, setX: (value) => { x = value; }, publications: () => publications, loads: () => loads }; + t.after(() => h.destroy()); + return { + state: h.state, engine, base, + get labels() { return labels; }, get options() { return options; }, get visible() { return visible; }, + projections, records, markers: h.parts.namedMarkers, lifecycle: h.parts.lifecycle.methods, + setX: (value) => { x = value; }, + /** A pose change too small for a camera event. */ + nudge() { height += 1; engine.fake.setPose({ height }); }, + frame: () => engine.fake.emit('frames.preRender', 0), + cameraChanged() { engine.fake.emit('camera.changed'); engine.fake.emit('camera.moveEnd'); }, + publications: () => publications, loads: h.loads, + }; } test('Contacts camera motion reprojects and declutters existing labels without source loads or primitive replacement', (t) => { const h = setup(t); h.markers.sync(h.records); assert.equal(h.markers.stats().pointsOnScreen, 0); - h.setX(600); h.camera.changed.raiseEvent(); h.camera.moveEnd.raiseEvent(); + h.setX(600); h.cameraChanged(); assert.equal(h.markers.stats().pointsOnScreen, 2); assert.equal(h.labels.length, 2); assert.equal(h.options.collisionCapacity, 24); @@ -44,22 +58,21 @@ test('Contacts camera motion reprojects and declutters existing labels without s assert.equal(h.loads(), 0); assert.equal(h.publications(), 3); assert.equal(h.projections.size, 1); - h.setX(1400); h.camera.viewMatrix[12] = 1; h.state.viewer.scene.preRender.raiseEvent(); + h.setX(1400); h.nudge(); h.frame(); assert.equal(h.markers.stats().pointsOnScreen, 0, 'small pose changes refresh before render'); h.lifecycle.disable(); - assert.equal(h.camera.changed.numberOfListeners, 1, 'only the viewport listener remains'); - assert.equal(h.state.viewer.scene.preRender.numberOfListeners, 0); + assert.equal(h.engine.camera.changed.numberOfListeners, h.base.changed + 1, 'only the viewport listener remains'); + assert.equal(h.engine.frames.preRender.numberOfListeners, h.base.preRender); assert.equal(h.labels.length, 0); assert.equal(h.visible, false); h.state.records = h.records; h.setX(600); h.lifecycle.enable(); assert.equal(h.labels.length, 2); assert.equal(h.publications(), 5); - const oldScene = h.state.viewer.scene; - h.lifecycle.destroy(h.state.viewer); - assert.equal(h.camera.changed.numberOfListeners, 0); - assert.equal(h.camera.moveEnd.numberOfListeners, 0); - assert.equal(oldScene.preRender.numberOfListeners, 0); + h.lifecycle.destroy(); + assert.equal(h.engine.camera.changed.numberOfListeners, h.base.changed); + assert.equal(h.engine.camera.moveEnd.numberOfListeners, h.base.moveEnd); + assert.equal(h.engine.frames.preRender.numberOfListeners, h.base.preRender); }); test('wide named markers retain primitives until close polygons publish with the same identity', async (t) => { @@ -81,7 +94,6 @@ test('wide named markers retain primitives until close polygons publish with the assert.equal(first.title, 'Site 1'); assert.equal(h.labels.length, 2, 'no duplicate title at handoff'); assert.equal(retainNamedHandoff(h.records, [], { west: 1, east: 2, south: 1, north: 2 }).length, 0); - h.lifecycle.destroy(h.state.viewer); }); test('canonical aliases transfer named marker ownership without replacing primitives', (t) => { @@ -108,7 +120,6 @@ test('canonical aliases transfer named marker ownership without replacing primit }); } assert.equal(h.labels.length, 1); - h.lifecycle.destroy(h.state.viewer); }); @@ -125,13 +136,13 @@ test('the bounded host cohort ranks wide points and polygon titles by area', (t) const first = h.labels[0]; const publications = h.publications(); for (let i = 0; i < 100; i++) { - h.camera.viewMatrix[12] += 0.01; - h.state.viewer.scene.preRender.raiseEvent(); + h.nudge(); + h.frame(); } assert.equal(h.labels[0], first); assert.equal(h.publications(), publications, 'camera frames never rebuild host entries'); assert.equal(h.projections.size, 1, 'point projection reuses scratch storage'); - h.lifecycle.destroy(h.state.viewer); + h.lifecycle.destroy(); assert.equal(h.labels.length, 0); }); @@ -146,7 +157,6 @@ test('wide and polygon titles use the Data Centers entry contract and persistent const reference = createLocalInfrastructureOverlayEntry({ id: entry.id, layerId: 'military-installations', position: entry.position, properties: { name: entry.title }, priority: entry.priority, accent: entry.accent }); assert.deepEqual(entry, { ...reference, selected: false }); } - h.lifecycle.destroy(h.state.viewer); }); @@ -167,7 +177,6 @@ test('selection promotes exactly one entry and deselection restores that same la assert.equal(original.variant, 'card'); assert.equal(original.selected, false); assert.deepEqual(original.details, []); - h.lifecycle.destroy(h.state.viewer); }); test('Contacts can select a point outside the normal title cap or an unnamed polygon', (t) => { @@ -182,7 +191,6 @@ test('Contacts can select a point outside the normal title cap or an unnamed pol assert.equal(h.labels.filter(e => e.selected).length, 1); assert.equal(h.labels.length, 512); } - h.lifecycle.destroy(h.state.viewer); }); test('selected markers without an explicit namedArea flag stay hidden safely', (t) => { @@ -196,5 +204,4 @@ test('selected markers without an explicit namedArea flag stay hidden safely', ( h.state.selectedId = record.id; assert.doesNotThrow(() => h.markers.sync([record])); h.markers.visit((_id, point) => assert.equal(point.show, false)); - h.lifecycle.destroy(h.state.viewer); }); diff --git a/src/layers/installations/navigation.test.mjs b/src/layers/installations/navigation.test.mjs index 0c1d9c308..b903e86f5 100644 --- a/src/layers/installations/navigation.test.mjs +++ b/src/layers/installations/navigation.test.mjs @@ -2,49 +2,50 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { createViewport } from './viewport.js'; -import { createLifecycle } from './render/cesium/lifecycle.js'; +import { createDisplayHarness } from './render/displayHarness.mjs'; +import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs'; test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and recover from wide views', (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); let rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8); - const camera = { - changed: new Cesium.Event(), - moveEnd: new Cesium.Event(), - percentageChanged: 0.5, - computeViewRectangle: () => rectangle, + const h = createDisplayHarness(createFakeGroundHarness); + const { engine, state, parts } = h; + engine.fake.script('viewRectangle', () => rectangle); + const camera = engine.camera; + const base = { + changed: camera.changed.numberOfListeners, + moveEnd: camera.moveEnd.numberOfListeners, + sensitivity: camera.percentageChanged, }; - const viewer = { - camera, - scene: { globe: { ellipsoid: Cesium.Ellipsoid.WGS84 } }, - dataSources: { add() {}, remove() {} }, - }; - const state = { enabled: false, renderedKeys: new Map() }; + const changed = () => engine.fake.emit('camera.changed'); + const moveEnd = () => engine.fake.emit('camera.moveEnd'); const loads = []; - const parts = { - selection: { installInteraction() {} }, - rendering: { clearRendered() {} }, - ingestion: { - loadInstallations() { - state.abort = new AbortController(); - loads.push({ - ...parts.viewport.loadArea(viewer), - signal: state.abort.signal, - }); - }, - setInstallationStatus() {}, + parts.ingestion = { + loadInstallations() { + state.abort = new AbortController(); + loads.push({ + ...parts.viewport.loadArea(null), + signal: state.abort.signal, + }); }, + setInstallationStatus() {}, }; - const services = { - picking: { registerPickOwner() {}, unregisterPickOwner() {} }, - context: { clearSelectedEntityContextForLayer() {} }, - }; - const ctx = { state, parts, services, source: {} }; - parts.viewport = createViewport(ctx); - const life = createLifecycle(ctx).methods; - life.init(viewer); + parts.viewport = createViewport({ + state, + parts, + services: h.services, + source: {}, + }); + const life = parts.lifecycle.methods; + life.init(); life.enable(); + assert.equal( + camera.percentageChanged, + 0.01, + 'the layer claims a fine camera', + ); for (let i = 0; i < 8; i++) { - camera.changed.raiseEvent(); + changed(); t.mock.timers.tick(15); } t.mock.timers.tick(200); @@ -53,7 +54,7 @@ test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and 1, 'changed alone must load, even without moveEnd', ); - camera.moveEnd.raiseEvent(); + moveEnd(); t.mock.timers.tick(200); assert.equal(loads.length, 1); assert.equal( @@ -62,12 +63,12 @@ test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and 'duplicate settle must not abort its own load', ); rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4); - camera.changed.raiseEvent(); + changed(); assert.equal(loads[0].signal.aborted, true); t.mock.timers.tick(200); assert.equal(loads.length, 2); rectangle = Cesium.Rectangle.fromDegrees(-150, 10, -80, 60); - camera.moveEnd.raiseEvent(); + moveEnd(); t.mock.timers.tick(200); assert.equal( loads.at(-1).box.west, @@ -75,25 +76,25 @@ test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and 'wide views reach the named-point source', ); rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8); - camera.changed.raiseEvent(); + changed(); t.mock.timers.tick(200); assert.ok(loads.at(-1).box); state.contextAnchor = { latitude: 32.8, longitude: -117.1 }; const count = loads.length; - camera.moveEnd.raiseEvent(); + moveEnd(); t.mock.timers.tick(200); assert.equal(loads.length, count, 'Contacts owns the anchor while active'); life.disable(); assert.equal(state.contextAnchor, null); life.enable(); rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4); - camera.changed.raiseEvent(); + changed(); t.mock.timers.tick(200); assert.equal(loads.at(-1).coverage.kind, 'viewport'); - life.destroy(viewer); - assert.equal(camera.changed.numberOfListeners, 0); - assert.equal(camera.moveEnd.numberOfListeners, 0); - assert.equal(camera.percentageChanged, 0.5); + h.destroy(); + assert.equal(camera.changed.numberOfListeners, base.changed); + assert.equal(camera.moveEnd.numberOfListeners, base.moveEnd); + assert.equal(camera.percentageChanged, base.sensitivity); }); test('an aborting real ingestion cannot clear the next scheduled view key', async (t) => { diff --git a/src/layers/installations/render/cesium.js b/src/layers/installations/render/cesium.js deleted file mode 100644 index a20cddf9b..000000000 --- a/src/layers/installations/render/cesium.js +++ /dev/null @@ -1,14 +0,0 @@ -import { createLifecycle } from './cesium/lifecycle.js'; -import { createNamedMarkers } from './cesium/namedMarkers.js'; -import { createRendering } from './cesium/rendering.js'; -import { createSelection } from './cesium/selection.js'; - -/** Compose the existing Cesium-owned scene, input and entity adapters. */ -export function createInstallationsCesiumRenderer(context) { - return { - lifecycle: createLifecycle(context), - namedMarkers: createNamedMarkers(context), - rendering: createRendering(context), - selection: createSelection(context), - }; -} diff --git a/src/layers/installations/render/cesium/lifecycle.js b/src/layers/installations/render/cesium/lifecycle.js deleted file mode 100644 index 6f3d75405..000000000 --- a/src/layers/installations/render/cesium/lifecycle.js +++ /dev/null @@ -1,121 +0,0 @@ -import * as Cesium from 'cesium'; -import { LAYER_ID } from '../../policy.js'; -import { - claimCameraSensitivity, - releaseCameraSensitivity, -} from '../../../../data/cameraSensitivity.js'; - -export function createLifecycle({ - state: layerState, - services, - parts, - source, -}) { - const { registerPickOwner, unregisterPickOwner } = services.picking; - const { clearSelectedEntityContextForLayer } = services.context; - const cameraOwner = (viewer) => layerState.engine ?? viewer?.camera; - - const methods = { - init(viewer, { engine = null } = {}) { - layerState.viewer = viewer; - layerState.engine = engine; - layerState.dataSource = new Cesium.CustomDataSource( - 'military-installations', - ); - const added = viewer.dataSources.add(layerState.dataSource); - const camera = engine?.camera ?? viewer.camera; - const cameraChanged = () => { - if (!layerState.contextAnchor) parts.viewport.scheduleLoad(); - }; - layerState.moveEndRemove = camera.moveEnd.addEventListener(cameraChanged); - layerState.changedRemove = - camera.changed?.addEventListener(cameraChanged); - parts.selection.installInteraction(viewer); - return added; - }, - - enable() { - layerState.enabled = true; - layerState.surfaceChangedRemove ||= services.maps?.subscribeMapStack( - parts.rendering.syncSelectionFill, - ); - // Cached markers and geometry become visible before the next fetch. - if ( - layerState.records?.some((r) => - r.sources?.some((s) => s.name === 'OpenStreetMap'), - ) - ) - services.credits?.showOsmCredit?.(layerState.viewer, LAYER_ID, { - openMapTiles: layerState.records.some((r) => - r.sources?.some( - (s) => s.name === 'OpenStreetMap' && s.id?.startsWith('tile:'), - ), - ), - }); - parts.namedMarkers?.enable(); - parts.namedMarkers?.sync(layerState.records || []); - 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 - // the first fetch. Avoid racing it with a second aborting request here. - }, - - disable() { - layerState.enabled = false; - services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID); - services.credits?.hideGooglePlacesCredit?.(layerState.viewer, LAYER_ID); - parts.namedMarkers?.hide(); - releaseCameraSensitivity(cameraOwner(layerState.viewer), LAYER_ID); - layerState.contextAnchor = null; - layerState.contextPosition = null; - layerState.coverage = { kind: 'viewport' }; - layerState.cameraLoadKey = null; - layerState.cameraLoadOwner = null; - unregisterPickOwner(LAYER_ID); - parts.viewport.clearUnavailableRetry(); - clearTimeout(layerState.timer); - layerState.abort?.abort(); - layerState.abort = null; - layerState.loading = false; - if (layerState.dataSource) layerState.dataSource.show = false; - clearSelectedEntityContextForLayer(LAYER_ID); - layerState.selectedId = null; - parts.rendering.clearSelectionFill?.(); - layerState.surfaceChangedRemove?.(); - layerState.surfaceChangedRemove = null; - layerState.failureReason = null; - parts.ingestion.setInstallationStatus('idle'); - }, - - destroy(viewer) { - source.destroy?.(); - this.disable(); - this.detach(viewer); - }, - - detach(viewer) { - services.credits?.hideOsmCredit?.(viewer, LAYER_ID); - services.credits?.hideGooglePlacesCredit?.(viewer, LAYER_ID); - unregisterPickOwner(LAYER_ID); - releaseCameraSensitivity(cameraOwner(viewer), LAYER_ID); - layerState.surfaceChangedRemove?.(); - layerState.surfaceChangedRemove = null; - layerState.moveEndRemove?.(); - layerState.changedRemove?.(); - layerState.moveEndRemove = layerState.changedRemove = null; - parts.selection.destroy?.(); - layerState.clickHandler?.destroy(); - layerState.clickHandler = null; - parts.namedMarkers?.destroy(); - parts.rendering.clearRendered(); - if (layerState.dataSource && viewer) - viewer.dataSources.remove(layerState.dataSource, true); - layerState.dataSource = null; - layerState.viewer = null; - layerState.engine = null; - }, - }; - - return { methods }; -} diff --git a/src/layers/installations/render/cesium/namedMarkers.js b/src/layers/installations/render/cesium/namedMarkers.js deleted file mode 100644 index 7b2c1dd84..000000000 --- a/src/layers/installations/render/cesium/namedMarkers.js +++ /dev/null @@ -1,225 +0,0 @@ -import { installationClassLabel, installationTitle } from '../../classLabel.js'; -import * as Cesium from 'cesium'; -import { - MILITARY_LABEL_CAP, - MILITARY_POINT_CAP, -} from '../../../../data/militaryNames.js'; -import { LAYER_ID, COLOR_BY_CLASS } from '../../policy.js'; -import { createInfrastructureOverlayEntry } from '../../../../data/infrastructureOverlayEntry.js'; - -/** Bounded point markers; the shared overlay owns text projection and decluttering. */ -export function createNamedMarkers({ state, services, parts, overlayHost }) { - let points = null; - let entries = []; - const markers = new Map(); - const screen = new Cesium.Cartesian2(); - const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84); - let shownPoints = 0; - let ordered = []; - const viewMatrix = new Cesium.Matrix4(); - let width = 0, - height = 0; - let removeChanged = null, - removeEnd = null, - removeFrame = null; - - function refresh() { - const viewer = state.viewer; - if (!state.enabled || !points) return; - width = viewer.scene.canvas.clientWidth; - height = viewer.scene.canvas.clientHeight; - if (viewer.camera.viewMatrix) - Cesium.Matrix4.clone(viewer.camera.viewMatrix, viewMatrix); - occluder.cameraPosition = viewer.camera.positionWC; - shownPoints = 0; - for (let index = 0; index < ordered.length; index++) { - const marker = ordered[index]; - const point = marker.point; - point.show = Boolean( - marker.named && occluder.isPointVisible(point.position), - ); - if (!point.show) continue; - const projected = Cesium.SceneTransforms.worldToWindowCoordinates( - viewer.scene, - point.position, - screen, - ); - if ( - projected && - screen.x >= 0 && - screen.y >= 0 && - screen.x < width && - screen.y < height - ) - shownPoints++; - } - } - - function publishLabels() { - overlayHost.setEntries(LAYER_ID, entries, { - // Keep the bounded point cohort available while Contacts owns the window. - // The arbiter spends at most 24 slots on currently visible, separated titles. - cohortLimit: MILITARY_POINT_CAP, - collisionCapacity: MILITARY_LABEL_CAP, - maxVisible: MILITARY_LABEL_CAP - (state.selectedId ? 1 : 0), - moving: false, - visible: state.enabled, - }); - } - - function cameraChanged() { - refresh(); - services.render.governorRequestRender('installation-names'); - } - function frame() { - const viewer = state.viewer; - if ( - viewer?.camera.viewMatrix && - (!Cesium.Matrix4.equals(viewMatrix, viewer.camera.viewMatrix) || - width !== viewer.scene.canvas.clientWidth || - height !== viewer.scene.canvas.clientHeight) - ) - refresh(); - } - function enable() { - const viewer = state.viewer; - if (!viewer || removeChanged || removeEnd || removeFrame) return; - removeChanged = viewer.camera.changed?.addEventListener(cameraChanged); - removeEnd = viewer.camera.moveEnd?.addEventListener(cameraChanged); - removeFrame = viewer.scene.preRender?.addEventListener(frame); - if (points) points.show = true; - publishLabels(); - overlayHost.setVisible(LAYER_ID, true); - cameraChanged(); - } - function hide() { - removeChanged?.(); - removeEnd?.(); - removeFrame?.(); - removeChanged = removeEnd = removeFrame = null; - if (points) points.show = false; - overlayHost.clearSource(LAYER_ID); - overlayHost.setVisible(LAYER_ID, false); - shownPoints = 0; - } - - function sync(records) { - const viewer = state.viewer; - if (!viewer?.scene?.primitives) return; - if (!points) { - points = viewer.scene.primitives.add( - new Cesium.PointPrimitiveCollection(), - ); - } - points.show = state.enabled; - const named = records - .filter((r) => r.namedArea || r.id === state.selectedId) - .sort( - (a, b) => - Number(b.id === state.selectedId) - - Number(a.id === state.selectedId) || b.areaM2 - a.areaM2, - ) - .slice(0, MILITARY_POINT_CAP); - const keep = new Set(named.map((r) => r.id)); - for (const record of named) { - if (markers.has(record.id)) continue; - const alias = record.aliasIds?.find( - (id) => !keep.has(id) && markers.has(id), - ); - if (!alias) continue; - const marker = markers.get(alias); - markers.delete(alias); - marker.point.id = { installationId: record.id }; - marker.entry.id = record.id; - markers.set(record.id, marker); - } - for (const [id, marker] of markers) - if (!keep.has(id)) { - points.remove(marker.point); - markers.delete(id); - } - ordered = []; - entries = []; - for (const record of named) { - const position = Cesium.Cartesian3.fromDegrees( - record.longitude, - record.latitude, - record.pointOnly - ? 0 - : parts.rendering.installationSurfaceHeightM(record), - ); - let marker = markers.get(record.id); - if (!marker) { - const id = { installationId: record.id }; - marker = { - point: points.add({ - id, - position, - pixelSize: 7, - color: parts.model.colorFor(record), - outlineColor: Cesium.Color.BLACK, - outlineWidth: 1, - disableDepthTestDistance: Infinity, - }), - entry: createInfrastructureOverlayEntry({ - id: record.id, - source: LAYER_ID, - position, - title: installationTitle(record.name), - priority: record.areaM2 || 0, - accent: COLOR_BY_CLASS[record.class] || '#9ca6b0', - }), - }; - markers.set(record.id, marker); - } else { - marker.point.position = position; - } - marker.named = record.namedArea; - marker.entry.variant = - record.id === state.selectedId ? 'selected' : 'card'; - marker.entry.selected = record.id === state.selectedId; - marker.entry.details = marker.entry.selected - ? [ - installationClassLabel(record.class), - ...(record.memberNames || []).slice(0, 3), - ] - : []; - marker.entry.position = position; - marker.entry.title = installationTitle(record.name); - marker.entry.priority = record.areaM2 || 0; - entries.push(marker.entry); - ordered.push(marker); - } - if (state.enabled) publishLabels(); - refresh(); - services.render.governorRequestRender('installation-names'); - } - return { - sync, - visit(visitor) { - for (let i = 0; i < ordered.length; i++) { - const marker = ordered[i]; - visitor(marker.point.id.installationId, marker.point); - } - }, - refresh, - enable, - hide, - stats: () => ({ - namedMarkers: markers.size, - pointsOnScreen: shownPoints, - labelsOnScreen: state.enabled - ? overlayHost.getDiagnostics?.().paintedBySource[LAYER_ID] || 0 - : 0, - labelCap: MILITARY_LABEL_CAP, - }), - destroy() { - hide(); - ordered = []; - if (points) state.viewer?.scene.primitives.remove(points); - points = null; - entries = []; - markers.clear(); - }, - }; -} diff --git a/src/layers/installations/render/cesium/rendering.js b/src/layers/installations/render/cesium/rendering.js deleted file mode 100644 index 7d8528d1d..000000000 --- a/src/layers/installations/render/cesium/rendering.js +++ /dev/null @@ -1,376 +0,0 @@ -import { installationClassLabel, installationTitle } from '../../classLabel.js'; -import * as Cesium from 'cesium'; -import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from '../../policy.js'; -import { t } from '../../../../i18n/index.js'; - -export function createRendering({ - state: layerState, - services, - parts, - source, -}) { - const { floorAltitudeM, cachedGroundFloor } = services.ground; - const { - removeEntityContextsForLayer, - getSelectedEntityContext, - registerEntityContext, - selectEntityContext, - } = services.context; - const { governorRequestRender } = services.render; - const { warmFireAnchorFloors } = services.anchors; - - /** - * Shared rendered-surface height for an installation anchor or footprint. - * @param {{latitude:number, longitude:number}} record Installation record. - * @returns {number} Ellipsoidal render height in metres. - */ - - function installationSurfaceHeightM(record) { - return ( - floorAltitudeM( - null, - cachedGroundFloor(record?.latitude, record?.longitude), - ) ?? 0 - ); - } - - let selectedFillKey = null; - const selectedFillIds = []; - - function clearSelectionFill() { - for (const id of selectedFillIds) - layerState.dataSource?.entities.removeById(id); - selectedFillIds.length = 0; - selectedFillKey = null; - } - - /** Ground geometry classifies the active surface; no height/extrusion plane. */ - function syncSelectionFill() { - const record = - layerState.enabled && layerState.recordById.get(layerState.selectedId); - if (!record) { - clearSelectionFill(); - return; - } - const classificationType = - layerState.viewer.scene.globe?.show === false - ? Cesium.ClassificationType.CESIUM_3D_TILE - : Cesium.ClassificationType.BOTH; - const key = `${record.id}|${renderKey(record)}|${classificationType}`; - if (key === selectedFillKey) return; - clearSelectionFill(); - selectedFillKey = key; - const footprints = - record.footprints || (record.footprint ? [[record.footprint]] : []); - const material = new Cesium.ColorMaterialProperty( - parts.model.colorFor(record).withAlpha(0.22), - ); - for (let i = 0; i < footprints.length; i++) { - const rings = footprints[i]; - const positions = (ring) => - ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)); - const fill = layerState.dataSource.entities.add({ - id: `${record.id}:selected-fill:${i}`, - polygon: { - hierarchy: new Cesium.PolygonHierarchy( - positions(rings[0]), - rings - .slice(1) - .map((ring) => new Cesium.PolygonHierarchy(positions(ring))), - ), - material, - classificationType, - }, - }); - fill.installationId = record.id; - selectedFillIds.push(fill.id); - } - governorRequestRender('installation-selection-fill'); - } - - function clearRendered() { - clearSelectionFill(); - if (layerState.dataSource?.entities) - layerState.dataSource.entities.removeAll(); - layerState.renderedKeys.clear(); - removeEntityContextsForLayer(LAYER_ID); - } - - /** - * Geometry identity of one rendered record. Records whose key is unchanged - * keep their Cesium entities across refreshes: ground-clamped outlines and - * classification fills rebuild asynchronously, so recreating them on every - * reload made unchanged sites blink. - * @param {object} record Installation record. - * @returns {string} Stable key for the record's drawn geometry. - */ - function renderKey(record) { - const footprints = - record.footprints || (record.footprint ? [[record.footprint]] : []); - const outlines = - record.outlineLines || (record.footprint ? [record.footprint] : []); - let points = 0; - for (const rings of footprints) - for (const ring of rings) points += ring.length; - for (const line of outlines) points += line.length; - return [ - record.latitude.toFixed(6), - record.longitude.toFixed(6), - record.class, - record.kind, - record.name, - footprints.length, - outlines.length, - points, - ].join('|'); - } - - function removeRecordEntities(id) { - const entities = layerState.dataSource.entities; - for (const entityId of layerState.renderedKeys.get(id)?.entityIds || []) - entities.removeById(entityId); - layerState.renderedKeys.delete(id); - } - - /** - * The records that get entities this paint: the nearest `MAX_RENDERED`, plus - * the selected one when it falls outside that window. - * - * Context navigation walks the FULL nearby cohort, which is not bounded by the - * render cap, so selecting item 701+ used to produce no entity at all — the - * camera flew, `getById` returned null, and the selection was silently dropped - * on the floor, leaving the Context subject stale so NEXT offered the same - * installation forever. One extra entity keeps every cohort item selectable - * and the cohort count honest. - * @returns {Array} Records to render this paint. - */ - - function renderableRecords() { - const rendered = layerState.records - .filter((r) => !r.pointOnly) - .slice(0, MAX_RENDERED); - if (!layerState.selectedId) return rendered; - if (rendered.some((record) => record.id === layerState.selectedId)) - return rendered; - const selected = layerState.recordById.get(layerState.selectedId); - return selected ? [...rendered, selected] : rendered; - } - - function syncPlacesCredit(records) { - const usesPlaces = records.some((record) => - record.sources?.some((source) => source.name === 'Google Maps Places'), - ); - if (usesPlaces) - services.credits?.showGooglePlacesCredit?.(layerState.viewer, LAYER_ID); - else - services.credits?.hideGooglePlacesCredit?.(layerState.viewer, LAYER_ID); - } - - function renderRecords({ claimSelection = false } = {}) { - // Context navigation can select another layer without a canvas click. - // A delayed floor/data repaint must not steal that newer selection back. - const selectedContext = getSelectedEntityContext(); - if ( - !claimSelection && - layerState.selectedId && - (!selectedContext || selectedContext.id !== layerState.selectedId) - ) { - layerState.selectedId = null; - } - let migratedSelection = false; - if ( - layerState.selectedId && - !layerState.recordById.has(layerState.selectedId) - ) { - const replacement = layerState.records.find((record) => - record.aliasIds?.includes(layerState.selectedId), - ); - if (replacement) { - layerState.selectedId = replacement.id; - migratedSelection = true; - } - } - // Post-moveEnd debounced fetches commit after the camera settles; the - // rebuilt entities need one frame in idle mode. (perf wave 2 fix) - governorRequestRender('installations-render'); - if ( - layerState.records.some((r) => - r.sources?.some((s) => s.name === 'OpenStreetMap'), - ) - ) - services.credits?.showOsmCredit?.(layerState.viewer, LAYER_ID, { - openMapTiles: layerState.records.some((r) => - r.sources?.some( - (s) => s.name === 'OpenStreetMap' && s.id?.startsWith('tile:'), - ), - ), - }); - else services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID); - syncPlacesCredit(layerState.records); - const records = renderableRecords(); - const keys = new Map( - records.map((record) => [record.id, renderKey(record)]), - ); - const entities = layerState.dataSource.entities; - entities.suspendEvents(); - for (const [id, rendered] of [...layerState.renderedKeys]) - if (keys.get(id) !== rendered.key) removeRecordEntities(id); - for (const record of records) { - const color = parts.model.colorFor(record); - const surfaceHeightM = installationSurfaceHeightM(record); - const displayPosition = Cesium.Cartesian3.fromDegrees( - record.longitude, - record.latitude, - surfaceHeightM, - ); - const selectedRecord = record.id === layerState.selectedId; - const retained = layerState.renderedKeys.has(record.id) - ? entities.getById(record.id) - : null; - if (retained) { - // Same geometry: update the marker in place (selection, floor height). - if (retained.gevInstallationHeightM !== surfaceHeightM) { - retained.gevInstallationHeightM = surfaceHeightM; - retained.position = displayPosition; - retained.gevDisplayPosition = () => displayPosition; - } - if (retained.gevInstallationSelected !== selectedRecord) { - retained.gevInstallationSelected = selectedRecord; - retained.point.pixelSize = selectedRecord ? 13 : 9; - retained.point.color = selectedRecord ? Cesium.Color.WHITE : color; - } - continue; - } - const entityIds = [record.id]; - layerState.renderedKeys.set(record.id, { - key: keys.get(record.id), - entityIds, - }); - const entity = layerState.dataSource.entities.add({ - id: record.id, - position: displayPosition, - point: { - show: !record.namedArea, - pixelSize: record.id === layerState.selectedId ? 13 : 9, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - color: - record.id === layerState.selectedId ? Cesium.Color.WHITE : color, - outlineColor: Cesium.Color.BLACK.withAlpha(0.8), - outlineWidth: 1, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - }); - const outlines = - record.outlineLines || (record.footprint ? [record.footprint] : []); - for (let i = 0; i < outlines.length; i++) { - const outline = layerState.dataSource.entities.add({ - id: `${record.id}:outline:${i}`, - polyline: { - positions: outlines[i].map(([lon, lat]) => - Cesium.Cartesian3.fromDegrees(lon, lat), - ), - width: 2, - material: new Cesium.ColorMaterialProperty(color.withAlpha(0.85)), - clampToGround: true, - classificationType: Cesium.ClassificationType.BOTH, - }, - }); - outline.installationId = record.id; - entityIds.push(outline.id); - } - entity.gevInstallationHeightM = surfaceHeightM; - entity.gevInstallationSelected = selectedRecord; - entity.gevTrackedId = `installations:${record.id}`; - entity.gevDisplayPosition = () => displayPosition; - entity.gevLabelModel = { - title: installationTitle(record.name) || t('MAPPED INSTALLATION'), - details: [ - installationClassLabel(record.class), - ...(record.memberNames || []).slice(0, 3), - ], - accent: COLOR_BY_CLASS[record.class] || '#9ca6b0', - }; - registerEntityContext(entity, { - id: record.id, - layerId: LAYER_ID, - layerName: - record.kind === 'place_candidate' - ? 'Military Site Search Candidates' - : 'Mapped Military Installations', - source: parts.model.installationSourceLabel(record), - label: record.name, - latitude: record.latitude, - longitude: record.longitude, - properties: { - class: record.class, - memberNames: record.memberNames || [], - primaryType: record.primaryType || null, - placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [], - validation: record.validation, - retrievedAt: record.retrievedAt, - }, - }); - } - entities.resumeEvents(); - const selectedEntity = layerState.selectedId - ? layerState.dataSource.entities.getById(layerState.selectedId) - : null; - if (selectedEntity && (claimSelection || migratedSelection)) - selectEntityContext(selectedEntity); - else if (!selectedEntity) layerState.selectedId = null; - // Re-register rebuilt geometry and migrate aliases before pruning old context. - removeEntityContextsForLayer(LAYER_ID, { retainIds: new Set(keys.keys()) }); - parts.namedMarkers?.sync(layerState.records); - syncSelectionFill(); - } - - /** - * Second paint for floors that missed the bounded pre-render deadline. - * - * `resolveGroundFloorCellsBounded` gives up after FLOOR_RESOLVE_DEADLINE_MS so - * a cold DEM can never hold the dots hostage — but the resolve keeps running - * and lands seconds later, and without this the records it covers stay pinned - * at ellipsoid height 0, sitting visibly under the 3D tiles (owner playtest - * 2026-08-18: "orange dots at the bottom"). - * - * This is the render -> warm -> re-render chain FIRMS already uses, with one - * difference the installations path forces: the trigger is whether a cell that - * was COLD AT PAINT TIME is warm now, not whether this particular batch warmed - * it. The bounded resolve above is still running against the same cells, so - * asking "did MY batch warm anything" would answer false exactly when the other - * resolve won the race — the common case. Still terminating: a set that is - * wholly cold afterwards re-renders zero times and the next camera-driven load - * retries. - * @param {Array} records Records just rendered. - * @returns {void} - */ - - function warmInstallationFloors(records) { - const cold = records - .filter( - (record) => - cachedGroundFloor(record.latitude, record.longitude) == null, - ) - .map((record) => ({ lat: record.latitude, lon: record.longitude })); - if (!cold.length) return; - const owner = layerState.dataSource; - warmFireAnchorFloors(cold).then(() => { - if (!layerState.enabled || !owner || layerState.dataSource !== owner) - return; - if ( - !cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null) - ) - return; - renderRecords(); - }); - } - return { - installationSurfaceHeightM, - clearSelectionFill, - syncSelectionFill, - clearRendered, - renderableRecords, - renderRecords, - warmInstallationFloors, - }; -} diff --git a/src/layers/installations/render/cesium/rendering.test.mjs b/src/layers/installations/render/cesium/rendering.test.mjs deleted file mode 100644 index 49b24b948..000000000 --- a/src/layers/installations/render/cesium/rendering.test.mjs +++ /dev/null @@ -1,72 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; -import { rgbaFromCss } from '../../../shared/color.js'; -import { createRendering } from './rendering.js'; - -test('Cesium installation outlines use a valid material and retain ground clamping', () => { - const id = 'osm:way:980001'; - const footprint = [ - [-122.438, 37.758], - [-122.402, 37.758], - [-122.402, 37.782], - [-122.438, 37.782], - ]; - const record = { - id, - kind: 'installation', - class: 'airfield', - name: 'QA Airfield', - latitude: 37.77, - longitude: -122.42, - footprint, - }; - const state = { - enabled: true, - selectedId: null, - records: [record], - recordById: new Map([[id, record]]), - renderedKeys: new Map(), - dataSource: new Cesium.CustomDataSource('military-installations'), - viewer: { scene: { globe: { show: false } } }, - }; - const services = { - ground: { floorAltitudeM: () => 125, cachedGroundFloor: () => ({}) }, - anchors: { warmFireAnchorFloors: async () => false }, - render: { governorRequestRender() {} }, - context: { - getSelectedEntityContext: () => null, - registerEntityContext() {}, - removeEntityContextsForLayer() {}, - selectEntityContext() {}, - }, - }; - const parts = { - model: { - colorFor: () => rgbaFromCss('#ff8000'), - installationSourceLabel: () => 'OpenStreetMap', - }, - }; - Object.assign( - parts, - createRendering({ state, services, parts, source: {} }), - ); - - parts.renderRecords(); - - const time = Cesium.JulianDate.now(); - const marker = state.dataSource.entities.getById(id); - const outline = state.dataSource.entities.getById(`${id}:outline:0`); - assert.equal( - marker.point.heightReference.getValue(time), - Cesium.HeightReference.CLAMP_TO_GROUND, - ); - assert.ok(outline.polyline.material instanceof Cesium.ColorMaterialProperty); - assert.equal(outline.polyline.clampToGround.getValue(time), true); - for (const position of outline.polyline.positions.getValue(time)) - assert.ok( - Math.abs( - Cesium.Cartographic.fromCartesian(position).height, - ) < 0.01, - ); -}); diff --git a/src/layers/installations/render/cesium/selection.js b/src/layers/installations/render/cesium/selection.js deleted file mode 100644 index ac3fc9e33..000000000 --- a/src/layers/installations/render/cesium/selection.js +++ /dev/null @@ -1,84 +0,0 @@ -import * as Cesium from 'cesium'; -import { isPointerFree } from '../../../../data/inputOwnership.js'; -import { LAYER_ID } from '../../policy.js'; - -export function createSelection({ - state: layerState, - services, - parts, - source, -}) { - const { clearSelectedEntityContextForLayer } = services.context; - - let removeContextListeners = null; - - function syncContextSelection(event) { - if (!layerState.enabled) return; - // Tracking layers publish this event before replacing the shared context. - const selected = - event?.type === 'gev:awareness-subject-selected' - ? event.detail - : services.context.getSelectedEntityContext(); - const next = - selected?.layerId === LAYER_ID && layerState.recordById.has(selected.id) - ? selected.id - : null; - if (next === layerState.selectedId) return; - layerState.selectedId = next; - parts.rendering.renderRecords(); - } - - function selectRecord(id) { - const record = layerState.recordById.get(id); - if (!record || !layerState.dataSource) return false; - layerState.selectedId = id; - parts.rendering.renderRecords({ claimSelection: true }); - // renderRecords drops selectedId when the record produced no entity. - return layerState.selectedId === id; - } - - function installInteraction(viewer) { - if (layerState.clickHandler) return; - const events = [ - 'gev:entity-selected', - 'gev:entity-selection-cleared', - 'gev:awareness-subject-selected', - ]; - for (const event of events) - globalThis.window?.addEventListener?.(event, syncContextSelection); - removeContextListeners = () => { - for (const event of events) - globalThis.window?.removeEventListener?.(event, syncContextSelection); - }; - layerState.clickHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); - layerState.clickHandler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the click. - if (!isPointerFree()) return; - if (!layerState.enabled) return; - const picked = viewer.scene.pick(click.position); - const id = - picked?.id?.installationId || - (typeof picked?.id?.id === 'string' ? picked.id.id : null); - if (id && layerState.recordById.has(id) && id !== layerState.selectedId) { - selectRecord(id); - } else if (layerState.selectedId) { - // Clicking the selected site again, empty map, or another contact - // releases this layer's selection. Clear only our shared context so a - // sibling click handler's newly selected aircraft/site stays intact. - layerState.selectedId = null; - clearSelectedEntityContextForLayer(LAYER_ID); - parts.rendering.renderRecords(); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - } - return { - selectRecord, - installInteraction, - destroy() { - removeContextListeners?.(); - removeContextListeners = null; - }, - }; -} diff --git a/src/layers/installations/render/display.js b/src/layers/installations/render/display.js new file mode 100644 index 000000000..d1c48bd0c --- /dev/null +++ b/src/layers/installations/render/display.js @@ -0,0 +1,710 @@ +// Mapped Installations on the globe contract (engine.movers, engine.ground, +// engine.input, engine.camera), the same on every engine: +// +// - each rendered site is a 9 px disc in its class colour with a 1 px black +// outline at 0.8, always on top (the selected site's is 13 px and white, +// in a set of one over the others); a named area's disc is not drawn, its +// named-area marker is (./namedMarkers.js); +// - every rendered site's outlines, named areas' too, are 2 px ground lines +// at 0.85 in its class colour, on terrain and 3D tiles; +// - the selected site's footprints are filled at 0.22 in its class colour, +// on the 3D tiles while they hide the globe, else on both. +// +// A pick of a disc, an outline or the fill names the site. The contract has +// no ground clamping: a disc sits on its site's cached ground floor (the +// ellipsoid until the floor resolves), and floors that resolve late paint +// again. A site whose geometry is unchanged keeps its context handle across +// repaints, and its outlines stay drawn while a changed set is rebuilt. + +import { t } from '../../../i18n/index.js'; +import * as geo from '../../../geo/index.js'; +import { isPointerFree } from '../../../data/inputOwnership.js'; +import { + claimCameraSensitivity, + releaseCameraSensitivity, +} from '../../../data/cameraSensitivity.js'; +import { installationClassLabel, installationTitle } from '../classLabel.js'; +import { LAYER_ID, MAX_RENDERED } from '../policy.js'; +import { createNamedMarkers } from './namedMarkers.js'; +import { + MARKER_ORDER, + MARKER_OUTLINE_COLOR, + MARKER_OUTLINE_PX, + MARKER_SIZE_PX, + OUTLINE_ALPHA, + OUTLINE_ORDER, + OUTLINE_WIDTH_PX, + SELECTED_MARKER_COLOR, + SELECTED_MARKER_ORDER, + SELECTED_MARKER_SIZE_PX, + SELECTION_FILL_ALPHA, + SELECTION_FILL_ORDER, + installationAccent, +} from './style.js'; + +export const MARKER_SET_ID = LAYER_ID; +export const SELECTED_MARKER_SET_ID = `${LAYER_ID}-selected`; +export const OUTLINE_SET_ID = `${LAYER_ID}-outlines`; +export const SELECTION_FILL_SET_ID = `${LAYER_ID}-selection-fill`; + +const CONTEXT_EVENTS = [ + 'gev:entity-selected', + 'gev:entity-selection-cleared', + 'gev:awareness-subject-selected', +]; + +const footprintsOf = (record) => + record.footprints || (record.footprint ? [[record.footprint]] : []); +const outlinesOf = (record) => + record.outlineLines || (record.footprint ? [record.footprint] : []); + +/** + * Geometry identity of one rendered record. A record whose key is unchanged + * keeps its handle and its drawn outlines across refreshes: ground geometry + * rebuilds a few frames later on Cesium, so redrawing unchanged sites on + * every reload made them blink. + * @param {object} record Installation record. + * @returns {string} Stable key for the record's drawn geometry. + */ +export function installationRenderKey(record) { + const footprints = footprintsOf(record); + const outlines = outlinesOf(record); + let points = 0; + for (const rings of footprints) + for (const ring of rings) points += ring.length; + for (const line of outlines) points += line.length; + return [ + record.latitude.toFixed(6), + record.longitude.toFixed(6), + record.class, + record.kind, + record.name, + footprints.length, + outlines.length, + points, + ].join('|'); +} + +/** + * A ground set whose rebuild never blanks what it drew: the next items go + * into a second set, and the first is emptied once the second is drawn. + */ +function createSteadyGroundSet(engine, { id, ...options }) { + const sets = [0, 1].map((n) => + engine.ground.createSet({ ...options, id: `${id}-${n}` }), + ); + let front = 0; + let pending = false; + let removeWatch = null; + const stopWatch = () => { + removeWatch?.(); + removeWatch = null; + }; + function settle() { + if (!pending || !sets[1 - front].ready()) return; + sets[front].replace([]); + sets[front].commit(); + front = 1 - front; + pending = false; + stopWatch(); + } + return { + ids: sets.map((set) => set.id), + replace(items) { + const next = sets[1 - front]; + next.replace(items); + next.commit(); + pending = true; + settle(); + if (pending) + removeWatch ??= engine.frames.postRender.addEventListener(settle); + }, + clear() { + stopWatch(); + pending = false; + for (const set of sets) { + set.replace([]); + set.commit(); + } + }, + show(visible) { + for (const set of sets) set.show(visible); + }, + destroy() { + stopWatch(); + for (const set of sets) set.destroy(); + }, + }; +} + +/** + * The layer's renderer parts on `engine`: `{ lifecycle: { methods }, + * namedMarkers, rendering, selection }` (../index.js assigns them to its + * parts). + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {{ state: object, services: object, parts: object, source: object, + * overlayHost: object }} context The layer's state, services, parts, + * source and the shared overlay that paints its titles. + */ +export function createInstallationsDisplay( + engine, + { state, services, parts, source, overlayHost }, +) { + if (!engine?.ground) + throw new TypeError('The installations display needs a globe engine'); + const { governorRequestRender } = services.render; + const { + clearSelectedEntityContextForLayer, + getSelectedEntityContext, + registerEntityContext, + removeEntityContextsForLayer, + selectEntityContext, + } = services.context; + const namedMarkers = createNamedMarkers(engine, { + state, + services, + parts, + overlayHost, + }); + + let initialized = false; + let visible = false; + // Bumped when the renderer detaches: late floor work stops there. + let generation = 0; + let normal = null, + selected = null, + outlines = null, + fill = null; + let fillDrape = null, + fillKey = null; + let outlineSignature = ''; + // Record id → { key, record, position, heightM, context handle fields }. + const handles = new Map(); + let drawn = []; + let removeClick = null, + removeContextListeners = null; + + const colorOf = (record) => parts.model.colorFor(record); + const positionAt = (record, heightM) => + geo.cartesian3.fromDegrees(record.longitude, record.latitude, heightM); + + function syncPlacesCredit(records) { + const usesPlaces = records.some((record) => + record.sources?.some((item) => item.name === 'Google Maps Places'), + ); + if (usesPlaces) + services.credits?.showGooglePlacesCredit?.(engine, LAYER_ID); + else services.credits?.hideGooglePlacesCredit?.(engine, LAYER_ID); + } + + /** The OSM credit while a loaded record comes from OpenStreetMap. */ + function syncOsmCredit(records) { + if ( + records.some((record) => + record.sources?.some((item) => item.name === 'OpenStreetMap'), + ) + ) + services.credits?.showOsmCredit?.(engine, LAYER_ID, { + openMapTiles: records.some((record) => + record.sources?.some( + (item) => + item.name === 'OpenStreetMap' && item.id?.startsWith('tile:'), + ), + ), + }); + else services.credits?.hideOsmCredit?.(engine, LAYER_ID); + } + + function createMarkerSet(id, sizePx, capacity, order) { + return engine.movers.createSet({ + id, + kind: 'point', + capacity, + order, + depthTest: 'always', + pickable: true, + point: { + sizePx, + outlinePx: MARKER_OUTLINE_PX, + outlineColor: MARKER_OUTLINE_COLOR, + }, + }); + } + + /** The discs of the drawn sites; none while the layer is hidden. */ + function paintMarkers() { + if (!initialized) return; + let count = 0; + let chosen = null; + if (visible) + for (const handle of drawn) { + const { record } = handle; + if (record.namedArea) continue; + if (record.id === state.selectedId) { + chosen = handle; + continue; + } + normal.set(count++, { + position: handle.position, + color: colorOf(record).rgba(), + pickId: record.id, + }); + } + normal.setCount(count); + selected.setCount(chosen ? 1 : 0); + if (chosen) + selected.set(0, { + position: chosen.position, + color: SELECTED_MARKER_COLOR, + pickId: chosen.record.id, + }); + normal.commit(); + selected.commit(); + } + + /** The drawn sites' outlines, rebuilt only when one of them changed. */ + function paintOutlines() { + const signature = drawn + .map((handle) => `${handle.record.id}#${handle.key}`) + .join('\n'); + if (signature === outlineSignature) return; + outlineSignature = signature; + const items = []; + for (const { record } of drawn) { + const color = colorOf(record).withAlpha(OUTLINE_ALPHA).rgba(); + for (const line of outlinesOf(record)) + items.push({ + positions: line, + color, + widthPx: OUTLINE_WIDTH_PX, + pickId: record.id, + }); + } + outlines.replace(items); + } + + function clearSelectionFill() { + if (fill && fillKey !== null) { + fill.replace([]); + fill.commit(); + } + fillKey = null; + } + + /** + * The selected site's footprints, draped on the 3D tiles while they show + * (the globe is hidden then), else on terrain and tiles. + */ + function syncSelectionFill() { + const record = + initialized && state.enabled && state.recordById.get(state.selectedId); + if (!record) { + clearSelectionFill(); + return; + } + const drape = engine.maps.photorealActive() ? 'tiles' : 'both'; + const key = `${record.id}|${installationRenderKey(record)}|${drape}`; + if (key === fillKey) return; + clearSelectionFill(); + fillKey = key; + if (!fill || fillDrape !== drape) { + fill?.destroy(); + fill = engine.ground.createSet({ + id: SELECTION_FILL_SET_ID, + kind: 'polygon', + order: SELECTION_FILL_ORDER, + pickable: true, + drape, + }); + fillDrape = drape; + } + const color = colorOf(record).withAlpha(SELECTION_FILL_ALPHA).rgba(); + fill.replace( + footprintsOf(record).map((rings) => ({ + rings, + fill: color, + pickId: record.id, + })), + ); + fill.commit(); + governorRequestRender('installation-selection-fill'); + } + + function clearRendered() { + clearSelectionFill(); + handles.clear(); + drawn = []; + outlineSignature = ''; + if (initialized) { + paintMarkers(); + outlines.clear(); + } + removeEntityContextsForLayer(LAYER_ID); + } + + /** + * The records drawn this paint: the nearest `MAX_RENDERED`, plus the + * selected one when it falls outside that window. + * + * Context navigation walks the FULL nearby cohort, which is not bounded by + * the render cap, so an item past it had no handle at all: the camera flew, + * the selection was silently dropped, and the Context subject stayed stale + * so NEXT offered the same installation forever. One extra handle keeps + * every cohort item selectable and the cohort count honest. + * @returns {Array} Records to render this paint. + */ + function renderableRecords() { + const rendered = state.records + .filter((r) => !r.pointOnly) + .slice(0, MAX_RENDERED); + if (!state.selectedId) return rendered; + if (rendered.some((record) => record.id === state.selectedId)) + return rendered; + const chosen = state.recordById.get(state.selectedId); + return chosen ? [...rendered, chosen] : rendered; + } + + function contextOf(record) { + return { + id: record.id, + layerId: LAYER_ID, + layerName: + record.kind === 'place_candidate' + ? 'Military Site Search Candidates' + : 'Mapped Military Installations', + source: parts.model.installationSourceLabel(record), + label: record.name, + latitude: record.latitude, + longitude: record.longitude, + properties: { + class: record.class, + memberNames: record.memberNames || [], + primaryType: record.primaryType || null, + placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [], + validation: record.validation, + retrievedAt: record.retrievedAt, + }, + }; + } + + /** A drawn site's context handle, with what the readouts read of it. */ + function createHandle(record, key, heightM) { + const handle = { + id: record.id, + key, + record, + heightM, + position: positionAt(record, heightM), + gevTrackedId: `installations:${record.id}`, + gevLabelModel: { + title: installationTitle(record.name) || t('MAPPED INSTALLATION'), + details: [ + installationClassLabel(record.class), + ...(record.memberNames || []).slice(0, 3), + ], + accent: installationAccent(record), + }, + }; + handle.gevDisplayPosition = () => handle.position; + registerEntityContext(handle, contextOf(record)); + return handle; + } + + function renderRecords({ claimSelection = false } = {}) { + if (!initialized) return; + // Context navigation can select another layer without a canvas click. + // A delayed floor/data repaint must not steal that newer selection back. + const selectedContext = getSelectedEntityContext(); + if ( + !claimSelection && + state.selectedId && + selectedContext?.id !== state.selectedId + ) + state.selectedId = null; + let migratedSelection = false; + if (state.selectedId && !state.recordById.has(state.selectedId)) { + const replacement = state.records.find((record) => + record.aliasIds?.includes(state.selectedId), + ); + if (replacement) { + state.selectedId = replacement.id; + migratedSelection = true; + } + } + // Post-moveEnd debounced fetches commit after the camera settles; the + // rebuilt sets need one frame in idle mode. + governorRequestRender('installations-render'); + syncOsmCredit(state.records); + syncPlacesCredit(state.records); + const records = renderableRecords(); + const keys = new Map( + records.map((record) => [record.id, installationRenderKey(record)]), + ); + for (const [id, handle] of [...handles]) + if (keys.get(id) !== handle.key) handles.delete(id); + drawn = records.map((record) => { + const heightM = parts.model.installationSurfaceHeightM(record); + const handle = handles.get(record.id); + if (!handle) { + const created = createHandle(record, keys.get(record.id), heightM); + handles.set(record.id, created); + return created; + } + // Same geometry: the disc moves to its floor in place. + handle.record = record; + if (handle.heightM !== heightM) { + handle.heightM = heightM; + handle.position = positionAt(record, heightM); + } + return handle; + }); + paintMarkers(); + paintOutlines(); + const chosen = state.selectedId ? handles.get(state.selectedId) : null; + if (chosen && (claimSelection || migratedSelection)) + selectEntityContext(chosen); + else if (!chosen) state.selectedId = null; + // Re-register rebuilt geometry and migrate aliases before pruning old context. + removeEntityContextsForLayer(LAYER_ID, { retainIds: new Set(keys.keys()) }); + namedMarkers.sync(state.records); + syncSelectionFill(); + } + + /** + * Second paint for floors that missed the bounded pre-render deadline. + * + * `resolveGroundFloorCellsBounded` gives up after FLOOR_RESOLVE_DEADLINE_MS so + * a cold DEM can never hold the dots hostage, but the resolve keeps running + * and lands seconds later; without this the records it covers stay pinned + * at ellipsoid height 0, visibly under the 3D tiles. + * + * The trigger is whether a cell that was COLD AT PAINT TIME is warm now, + * not whether this batch warmed it: the bounded resolve is still running + * against the same cells and usually wins the race. Still terminating: a + * set that is wholly cold afterwards re-renders zero times and the next + * camera-driven load retries. + * @param {Array} records Records just rendered. + * @returns {void} + */ + function warmInstallationFloors(records) { + const { cachedGroundFloor } = services.ground; + const cold = records + .filter( + (record) => + cachedGroundFloor(record.latitude, record.longitude) == null, + ) + .map((record) => ({ lat: record.latitude, lon: record.longitude })); + if (!cold.length) return; + const epoch = generation; + services.anchors.warmFireAnchorFloors(cold).then(() => { + if (!state.enabled || !initialized || epoch !== generation) return; + if ( + !cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null) + ) + return; + renderRecords(); + }); + } + + function syncContextSelection(event) { + if (!state.enabled) return; + // Tracking layers publish this event before replacing the shared context. + const current = + event?.type === 'gev:awareness-subject-selected' + ? event.detail + : getSelectedEntityContext(); + const next = + current?.layerId === LAYER_ID && state.recordById.has(current.id) + ? current.id + : null; + if (next === state.selectedId) return; + state.selectedId = next; + renderRecords(); + } + + function selectRecord(id) { + if (!state.recordById.has(id) || !initialized) return false; + state.selectedId = id; + renderRecords({ claimSelection: true }); + // renderRecords drops selectedId when the record got no handle. + return state.selectedId === id; + } + + function onClick({ picked }) { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree()) return; + if (!state.enabled) return; + const id = typeof picked?.id === 'string' ? picked.id : null; + if (id && state.recordById.has(id) && id !== state.selectedId) { + selectRecord(id); + } else if (state.selectedId) { + // Clicking the selected site again, empty map, or another contact + // releases this layer's selection. Clear only our shared context so a + // sibling click handler's newly selected aircraft/site stays intact. + state.selectedId = null; + clearSelectedEntityContextForLayer(LAYER_ID); + renderRecords(); + } + } + + function installInteraction() { + if (removeClick) return; + for (const type of CONTEXT_EVENTS) + globalThis.window?.addEventListener?.(type, syncContextSelection); + removeContextListeners = () => { + for (const type of CONTEXT_EVENTS) + globalThis.window?.removeEventListener?.(type, syncContextSelection); + }; + removeClick = engine.input.on('click', onClick, { pick: true }); + } + + function destroyInteraction() { + removeContextListeners?.(); + removeContextListeners = null; + removeClick?.(); + removeClick = null; + } + + function setVisible(next) { + visible = next; + paintMarkers(); + outlines?.show(next); + } + + const methods = { + init() { + if (initialized) + throw new Error('Installations display is already initialized'); + state.engine = engine; + normal = createMarkerSet( + MARKER_SET_ID, + MARKER_SIZE_PX, + MAX_RENDERED + 1, + MARKER_ORDER, + ); + selected = createMarkerSet( + SELECTED_MARKER_SET_ID, + SELECTED_MARKER_SIZE_PX, + 1, + SELECTED_MARKER_ORDER, + ); + outlines = createSteadyGroundSet(engine, { + id: OUTLINE_SET_ID, + kind: 'corridor', + order: OUTLINE_ORDER, + pickable: true, + }); + outlines.show(false); + initialized = true; + const cameraChanged = () => { + if (!state.contextAnchor) parts.viewport.scheduleLoad(); + }; + state.moveEndRemove = + engine.camera.moveEnd.addEventListener(cameraChanged); + state.changedRemove = + engine.camera.changed.addEventListener(cameraChanged); + installInteraction(); + }, + + enable() { + state.enabled = true; + state.surfaceChangedRemove ||= + services.maps?.subscribeMapStack(syncSelectionFill); + // Cached markers and geometry become visible before the next fetch. + if ( + state.records?.some((r) => + r.sources?.some((s) => s.name === 'OpenStreetMap'), + ) + ) + syncOsmCredit(state.records); + namedMarkers.enable(); + namedMarkers.sync(state.records || []); + claimCameraSensitivity(engine, LAYER_ID, 0.01); + services.picking.registerPickOwner(LAYER_ID, (id) => + state.recordById.has(id), + ); + setVisible(true); + // The layer manager calls update() right after enable(), which owns + // the first fetch. A second, aborting request here would race it. + }, + + disable() { + state.enabled = false; + services.credits?.hideOsmCredit?.(engine, LAYER_ID); + services.credits?.hideGooglePlacesCredit?.(engine, LAYER_ID); + namedMarkers.hide(); + releaseCameraSensitivity(engine, LAYER_ID); + state.contextAnchor = null; + state.contextPosition = null; + state.coverage = { kind: 'viewport' }; + state.cameraLoadKey = null; + state.cameraLoadOwner = null; + services.picking.unregisterPickOwner(LAYER_ID); + parts.viewport.clearUnavailableRetry(); + clearTimeout(state.timer); + state.abort?.abort(); + state.abort = null; + state.loading = false; + setVisible(false); + clearSelectedEntityContextForLayer(LAYER_ID); + state.selectedId = null; + clearSelectionFill(); + state.surfaceChangedRemove?.(); + state.surfaceChangedRemove = null; + state.failureReason = null; + parts.ingestion.setInstallationStatus('idle'); + }, + + destroy() { + source.destroy?.(); + this.disable(); + this.detach(); + }, + + detach() { + services.credits?.hideOsmCredit?.(engine, LAYER_ID); + services.credits?.hideGooglePlacesCredit?.(engine, LAYER_ID); + services.picking.unregisterPickOwner(LAYER_ID); + releaseCameraSensitivity(engine, LAYER_ID); + state.surfaceChangedRemove?.(); + state.surfaceChangedRemove = null; + state.moveEndRemove?.(); + state.changedRemove?.(); + state.moveEndRemove = state.changedRemove = null; + destroyInteraction(); + namedMarkers.destroy(); + clearRendered(); + generation++; + if (initialized) { + normal.destroy(); + selected.destroy(); + outlines.destroy(); + fill?.destroy(); + } + normal = selected = outlines = fill = null; + fillDrape = null; + initialized = false; + visible = false; + state.engine = null; + }, + }; + + return { + lifecycle: { methods }, + namedMarkers, + rendering: { + installationSurfaceHeightM: parts.model.installationSurfaceHeightM, + clearSelectionFill, + syncSelectionFill, + clearRendered, + renderableRecords, + renderRecords, + warmInstallationFloors, + }, + selection: { + selectRecord, + installInteraction, + destroy: destroyInteraction, + }, + }; +} diff --git a/src/layers/installations/render/display.test.mjs b/src/layers/installations/render/display.test.mjs new file mode 100644 index 000000000..57cac750c --- /dev/null +++ b/src/layers/installations/render/display.test.mjs @@ -0,0 +1,419 @@ +// Mapped Installations on the engine contract, on the fake engine and on the +// Cesium engine: the sites' discs, the named areas' outlines, the ground +// outlines and the selection fill, what a pick selects, the context handles, +// late floors 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 * as context from '../../../data/contextStore.js'; +import { claimPointer, releasePointer } from '../../../data/inputOwnership.js'; +import { setLocale } from '../../../i18n/index.js'; +import { rgbaFromCss } from '../../shared/color.js'; +import { installationClassLabel, installationTitle } from '../classLabel.js'; +import { COLOR_BY_CLASS, LAYER_ID } from '../policy.js'; +import { + MARKER_SET_ID, + OUTLINE_SET_ID, + SELECTED_MARKER_SET_ID, + SELECTION_FILL_SET_ID, +} from './display.js'; +import { NAMED_MARKER_SET_ID } from './namedMarkers.js'; +import { createDisplayHarness } from './displayHarness.mjs'; +import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs'; + +const ring = (west, south, size = 0.01) => [ + [west, south], + [west + size, south], + [west + size, south + size], + [west, south + size], + [west, south], +]; + +const airfield = () => ({ + id: 'osm:way:1', + kind: 'installation', + class: 'airfield', + name: 'QA Airfield', + latitude: 30.005, + longitude: -96.995, + footprint: ring(-97, 30), + sources: [{ name: 'OpenStreetMap', id: 'way:1' }], +}); + +const namedRange = () => ({ + id: 'osm:military:w2', + kind: 'installation', + class: 'range', + name: 'Named Range', + namedArea: true, + areaM2: 5e6, + latitude: 30.105, + longitude: -97.095, + footprints: [[ring(-97.1, 30.1), ring(-97.098, 30.102, 0.002)]], + outlineLines: [ring(-97.1, 30.1), ring(-97.098, 30.102, 0.002)], +}); + +const classColor = (record, alpha) => + rgbaFromCss(COLOR_BY_CLASS[record.class]).withAlpha(alpha).rgba(); + +function closeTo(actual, expected, message) { + assert.equal(actual.length, expected.length, message); + actual.forEach((value, index) => + assert.ok(Math.abs(value - expected[index]) < 1e-6, message), + ); +} + +/** A started display over Texas, with the window the context store needs. */ +function setup(t, makeHarness, records = [airfield()], options) { + const previousWindow = globalThis.window; + globalThis.window = new EventTarget(); + const h = createDisplayHarness(makeHarness, options); + h.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(-97, 30, 50_000), + }); + h.setRecords(records); + h.start(); + t.after(() => { + h.destroy(); + context.removeEntityContextsForLayer(LAYER_ID); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + }); + return h; +} + +/** The outline set holding the drawn outlines (the other one is empty). */ +function drawnOutlines(h) { + const sets = [0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`)); + return sets.find((set) => set.items.length) ?? sets[1]; +} + +for (const makeHarness of GROUND_HARNESSES) { + const { name } = makeHarness(); + + test(`sites are 9 px discs in their class colour, 13 px and white when selected, always on top (${name})`, (t) => { + const h = setup(t, makeHarness); + h.parts.rendering.renderRecords(); + const [disc] = h.markers(MARKER_SET_ID); + assert.equal(h.markers(MARKER_SET_ID).length, 1); + assert.equal(disc.pickId, 'osm:way:1', 'a pick names the site'); + closeTo(disc.color, classColor(airfield(), 1)); + assert.equal(h.markers(SELECTED_MARKER_SET_ID).length, 0); + if (h.cesium) { + const point = disc.point; + assert.ok( + h.collection(MARKER_SET_ID) instanceof Cesium.PointPrimitiveCollection, + ); + assert.equal(point.pixelSize, 9); + assert.equal(point.outlineWidth, 1); + assert.ok( + point.outlineColor.equalsEpsilon( + Cesium.Color.BLACK.withAlpha(0.8), + 1e-6, + ), + ); + assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY); + } else { + const { options } = h.engine.fake.moverSet(MARKER_SET_ID); + assert.equal(options.kind, 'point'); + assert.equal(options.point.sizePx, 9); + assert.equal(options.point.outlinePx, 1); + assert.deepEqual(options.point.outlineColor, [0, 0, 0, 0.8]); + assert.equal(options.depthTest, 'always'); + assert.equal(options.pickable, true); + assert.ok(options.order < 0, 'under the moving contacts'); + const selected = h.engine.fake.moverSet(SELECTED_MARKER_SET_ID).options; + assert.equal(selected.point.sizePx, 13); + assert.ok(selected.order > options.order, 'over the other discs'); + } + + assert.equal(h.parts.selection.selectRecord('osm:way:1'), true); + assert.equal(h.markers(MARKER_SET_ID).length, 0); + const [chosen] = h.markers(SELECTED_MARKER_SET_ID); + assert.equal(chosen.pickId, 'osm:way:1'); + closeTo(chosen.color, [1, 1, 1, 1]); + if (h.cesium) assert.equal(chosen.point.pixelSize, 13); + }); + + test(`a named area draws no disc but its outlines are drawn, as on Cesium (${name})`, (t) => { + const named = namedRange(); + const h = setup(t, makeHarness, [airfield(), named]); + h.parts.rendering.renderRecords(); + assert.deepEqual( + h.markers(MARKER_SET_ID).map((disc) => disc.pickId), + ['osm:way:1'], + 'the named area has no site disc', + ); + const outlines = drawnOutlines(h).items.filter( + (item) => item.pickId === named.id, + ); + assert.equal(outlines.length, 2, 'every outline line of the named area'); + assert.deepEqual(outlines[0].positions, named.outlineLines[0]); + closeTo(outlines[0].color, classColor(named, 0.85)); + const namedDiscs = h.markers(NAMED_MARKER_SET_ID); + assert.equal( + namedDiscs[0].pickId, + named.id, + 'its named-area disc is drawn', + ); + assert.equal(namedDiscs[0].show, true); + // Selected, it still draws no site disc, and its outlines stay. + h.parts.selection.selectRecord(named.id); + assert.equal(h.markers(SELECTED_MARKER_SET_ID).length, 0); + assert.equal( + drawnOutlines(h).items.filter((item) => item.pickId === named.id).length, + 2, + ); + }); + + test(`outlines are 2 px ground lines at 0.85 on both surfaces, rebuilt only for a changed site (${name})`, (t) => { + const h = setup(t, makeHarness); + h.parts.rendering.renderRecords(); + const set = drawnOutlines(h); + assert.equal(set.options.kind, 'corridor'); + assert.equal(set.options.drape, 'both'); + assert.equal(set.options.pickable, true); + assert.equal(set.items.length, 1); + const [line] = set.items; + assert.equal(line.widthPx, 2); + assert.equal(line.pickId, 'osm:way:1'); + closeTo(line.color, classColor(airfield(), 0.85)); + if (h.cesium) { + const [primitive] = h.primitives(`${OUTLINE_SET_ID}-1`).lines; + assert.equal( + primitive.options.classificationType, + Cesium.ClassificationType.BOTH, + ); + } + // Cesium builds ground lines in workers: the first set is drawn now. + const built = (n) => { + for (const primitive of h.primitives(`${OUTLINE_SET_ID}-${n}`).lines) + primitive.ready = true; + h.viewer.scene.postRender.raiseEvent(); + }; + if (h.cesium) built(1); + const commits = [0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).commits); + for (let i = 0; i < 5; i++) h.parts.rendering.renderRecords(); + assert.deepEqual( + [0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).commits), + commits, + 'repaints with the same geometry rebuild nothing', + ); + + // A changed site rebuilds the set; the drawn outlines stay until the + // new ones are drawn. + h.setRecords([ + airfield(), + { ...airfield(), id: 'osm:way:3', latitude: 30.2 }, + ]); + h.parts.rendering.renderRecords(); + const counts = () => + [0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).items.length); + if (h.cesium) { + assert.deepEqual( + counts(), + [2, 1], + 'Cesium is still building the new set', + ); + built(0); + } + assert.deepEqual(counts(), [2, 0]); + }); + + test(`the selected footprints fill at 0.22 with their holes, on the tiles under photoreal (${name})`, (t) => { + const named = namedRange(); + const h = setup(t, makeHarness, [named]); + h.setPhotoreal(true); + h.parts.rendering.renderRecords(); + assert.equal( + h.set(SELECTION_FILL_SET_ID), + undefined, + 'no fill before a selection', + ); + h.parts.selection.selectRecord(named.id); + const fill = h.set(SELECTION_FILL_SET_ID); + assert.equal(fill.options.drape, 'tiles'); + assert.equal(fill.options.kind, 'polygon'); + assert.ok( + fill.options.order < drawnOutlines(h).options.order, + 'under the outlines', + ); + assert.equal(fill.items.length, 1, 'one polygon per footprint'); + assert.equal(fill.items[0].rings.length, 2, 'with its hole'); + assert.equal(fill.items[0].pickId, named.id); + assert.equal(fill.items[0].outline, undefined); + closeTo(fill.items[0].fill, classColor(named, 0.22)); + if (h.cesium) { + const [primitive] = h.primitives(SELECTION_FILL_SET_ID).fills; + assert.equal( + primitive.options.classificationType, + Cesium.ClassificationType.CESIUM_3D_TILE, + ); + } + const commits = fill.commits; + for (let i = 0; i < 10; i++) h.parts.rendering.renderRecords(); + assert.equal( + h.set(SELECTION_FILL_SET_ID).commits, + commits, + 'repaints keep the fill', + ); + + // The map stack changes to the globe: the fill drapes on both. + h.setPhotoreal(false); + h.changeMapStack(); + assert.equal(h.set(SELECTION_FILL_SET_ID).options.drape, 'both'); + assert.equal(h.set(SELECTION_FILL_SET_ID).items.length, 1); + + h.parts.lifecycle.methods.disable(); + assert.equal(h.set(SELECTION_FILL_SET_ID).items.length, 0); + assert.equal( + h.mapStackRemoved(), + 1, + 'disable drops the map-stack listener', + ); + h.parts.lifecycle.methods.enable(); + h.parts.rendering.syncSelectionFill(); + assert.equal( + h.set(SELECTION_FILL_SET_ID).items.length, + 0, + 'reenabling cannot revive a stale selection', + ); + }); + + test(`a click on a disc, an outline or the fill selects the site; the next click releases it (${name})`, (t) => { + const h = setup(t, makeHarness); + h.parts.rendering.renderRecords(); + h.click({ id: 'osm:way:1' }); + assert.equal(h.state.selectedId, 'osm:way:1'); + assert.equal(context.getSelectedEntityContext()?.id, 'osm:way:1'); + h.click({ id: 'osm:way:1' }); + assert.equal( + h.state.selectedId, + null, + 'clicking the selected site releases it', + ); + assert.equal(context.getSelectedEntityContext(), null); + + h.click({ set: `${OUTLINE_SET_ID}-1`, instance: 0 }); + assert.equal( + h.state.selectedId, + 'osm:way:1', + 'an outline selects its site', + ); + h.click({ id: 'aircraft:1' }); + assert.equal(h.state.selectedId, null, 'another contact releases it'); + h.parts.selection.selectRecord('osm:way:1'); + h.click({ set: SELECTION_FILL_SET_ID, instance: 0 }); + assert.equal( + h.state.selectedId, + null, + 'the fill of the selected site releases it', + ); + + const lease = claimPointer('draw'); + try { + h.click({ id: 'osm:way:1' }); + assert.equal( + h.state.selectedId, + null, + 'a tool that owns the pointer keeps it', + ); + } finally { + releasePointer(lease); + } + h.parts.lifecycle.methods.disable(); + h.click({ id: 'osm:way:1' }); + assert.equal(h.state.selectedId, null, 'a disabled layer ignores clicks'); + }); + + test(`each drawn site has a context handle on its floor, kept while its geometry is unchanged (${name})`, (t) => { + const h = setup(t, makeHarness); + const record = airfield(); + h.floors.set(`${record.latitude},${record.longitude}`, 125); + try { + setLocale('ru'); + h.parts.rendering.renderRecords(); + const handle = context.getContextStore().entities.get(record.id).entity; + assert.equal(handle.gevTrackedId, `installations:${record.id}`); + assert.deepEqual(handle.gevLabelModel, { + title: installationTitle('QA Airfield'), + details: [installationClassLabel('airfield')], + accent: COLOR_BY_CLASS.airfield, + }); + assert.notEqual( + handle.gevLabelModel.details[0], + 'AIRFIELD', + 'in the UI language', + ); + const height = geo.cartographic.fromCartesian( + handle.gevDisplayPosition(), + ).height; + assert.ok(Math.abs(height - 125) < 0.01, 'on the cached ground floor'); + const entry = context.getContextStore().entities.get(record.id); + assert.equal(entry.layerName, 'Mapped Military Installations'); + assert.equal(entry.source, 'OpenStreetMap'); + + h.parts.rendering.renderRecords(); + assert.equal( + context.getContextStore().entities.get(record.id).entity, + handle, + 'a repaint with the same geometry keeps the handle', + ); + h.setRecords([{ ...record, latitude: record.latitude + 0.001 }]); + h.parts.rendering.renderRecords(); + assert.notEqual( + context.getContextStore().entities.get(record.id).entity, + handle, + ); + } finally { + setLocale('en'); + } + }); + + test(`floors that resolve late paint the discs again (${name})`, async (t) => { + let warm; + const h = setup(t, makeHarness, [airfield()], { + warmFloors: () => new Promise((resolve) => (warm = resolve)), + }); + const record = airfield(); + h.parts.rendering.renderRecords(); + const heightOf = () => + geo.cartographic.fromCartesian(h.markers(MARKER_SET_ID)[0].position) + .height; + assert.ok( + Math.abs(heightOf()) < 0.01, + 'a cold floor sits on the ellipsoid', + ); + h.parts.rendering.warmInstallationFloors([record]); + h.floors.set(`${record.latitude},${record.longitude}`, 2400); + warm(true); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok( + Math.abs(heightOf() - 2400) < 0.01, + 'the late floor lifts the disc', + ); + }); + + test(`teardown removes every set and listener (${name})`, (t) => { + const h = setup(t, makeHarness, [airfield(), namedRange()]); + const changed = h.engine.camera.changed.numberOfListeners; + h.parts.rendering.renderRecords(); + h.parts.selection.selectRecord('osm:way:1'); + assert.ok(h.listeners() > 0); + h.parts.lifecycle.methods.detach(); + assert.equal(h.listeners(), 0, 'no click listener'); + assert.equal( + h.engine.camera.changed.numberOfListeners, + changed - 2, + 'the viewport and named-marker camera listeners go', + ); + assert.deepEqual( + h.engine.movers.list().filter(({ id }) => id.startsWith(LAYER_ID)), + [], + ); + assert.equal(h.sets(), 0, 'no ground set'); + assert.equal(context.getContextStore().entities.has('osm:way:1'), false); + }); +} diff --git a/src/layers/installations/render/displayHarness.mjs b/src/layers/installations/render/displayHarness.mjs new file mode 100644 index 000000000..61e8367c0 --- /dev/null +++ b/src/layers/installations/render/displayHarness.mjs @@ -0,0 +1,158 @@ +// Mapped Installations' display under test, on a ground harness of +// src/testSupport/groundDisplayEngines.mjs (the fake engine or the Cesium +// engine, passed in): the layer's own +// state and model over test services, with the display's parts assigned as +// the layer assigns them (../index.js). `markers(setId)` reads a point +// set's drawn instances on either engine. + +import * as contextStore from '../../../data/contextStore.js'; +import { createModel } from '../model.js'; +import { createState } from '../state.js'; +import { createInstallationsDisplay } from './display.js'; + +const children = (collection) => + Array.from({ length: collection?.length ?? 0 }, (_, index) => + collection.get(index), + ); + +/** The Cesium collection drawing a mover set. */ +function cesiumCollection(viewer, id) { + for (const root of children(viewer.scene.primitives)) + for (const child of children(root)) + if (child.gevMoverSet === id) return child; + return undefined; +} + +/** + * @param {Function} makeEngine A ground harness factory. + * @param {{ context?: object, overlayHost?: object, + * warmFloors?: (cells: object[]) => Promise }} [options] + * `context` replaces the shared context store. + */ +export function createDisplayHarness( + makeEngine, + { context = contextStore, overlayHost = null, warmFloors = null } = {}, +) { + // The context store lives on the window. + const ownWindow = globalThis.window === undefined; + if (ownWindow) globalThis.window = new EventTarget(); + const h = makeEngine(); + const state = createState({ services: {} }); + const floors = new Map(); + const labels = { entries: [], policy: null, visible: false, publications: 0 }; + let mapStackListener = null; + let mapStackRemoved = 0; + let loads = 0; + let photoreal = false; + h.engine.maps.photorealActive = () => photoreal; + const services = { + render: { governorRequestRender() {} }, + context, + ground: { + cachedGroundFloor: (lat, lon) => floors.get(`${lat},${lon}`) ?? null, + floorAltitudeM: (_position, floor) => floor ?? null, + }, + anchors: { warmFireAnchorFloors: warmFloors ?? (async () => false) }, + picking: { registerPickOwner() {}, unregisterPickOwner() {} }, + maps: { + subscribeMapStack(callback) { + mapStackListener = callback; + return () => { + mapStackListener = null; + mapStackRemoved++; + }; + }, + }, + }; + const parts = {}; + const ctx = { + state, + services, + parts, + source: {}, + overlayHost: overlayHost ?? { + setEntries(_id, entries, policy) { + labels.entries = entries; + labels.policy = policy; + labels.publications++; + }, + clearSource() { + labels.entries = []; + }, + setVisible(_id, visible) { + labels.visible = visible; + }, + }, + }; + parts.model = createModel(ctx); + parts.viewport = { + scheduleLoad: () => loads++, + clearUnavailableRetry() {}, + }; + parts.ingestion = { setInstallationStatus() {} }; + Object.assign(parts, createInstallationsDisplay(h.engine, ctx)); + const methods = parts.lifecycle.methods; + return { + ...h, + state, + services, + parts, + labels, + floors, + loads: () => loads, + setRecords(records) { + state.records = records; + state.recordById = new Map(records.map((record) => [record.id, record])); + }, + /** init() and enable(), as the layer manager runs them. */ + start() { + methods.init(); + methods.enable(); + }, + setPhotoreal(value) { + photoreal = value; + }, + changeMapStack: () => mapStackListener?.(), + mapStackRemoved: () => mapStackRemoved, + /** A point set's drawn instances: pickId, color (rgba 0..1), show, position. */ + markers(id) { + const count = + h.engine.movers.list().find((entry) => entry.id === id)?.count ?? 0; + if (h.cesium) { + const collection = cesiumCollection(h.viewer, id); + return children(collection) + .slice(0, count) + .map((point) => ({ + pickId: point.id, + color: [ + point.color.red, + point.color.green, + point.color.blue, + point.color.alpha, + ], + show: point.show, + position: point.position, + point, + })); + } + const { store } = h.engine.fake.moverSet(id); + return Array.from({ length: count }, (_, index) => ({ + pickId: store.pickIds[index], + color: Array.from(store.colors.slice(index * 4, index * 4 + 4)), + show: store.shows[index] === 1, + position: { + x: store.positions[index * 3], + y: store.positions[index * 3 + 1], + z: store.positions[index * 3 + 2], + }, + })); + }, + /** The Cesium collection of a point set (Cesium only). */ + collection: (id) => cesiumCollection(h.viewer, id), + destroy() { + methods.destroy(); + h.destroy(); + if (ownWindow) delete globalThis.window; + }, + }; +} diff --git a/src/layers/installations/render/namedMarkers.js b/src/layers/installations/render/namedMarkers.js new file mode 100644 index 000000000..33aae6fd2 --- /dev/null +++ b/src/layers/installations/render/namedMarkers.js @@ -0,0 +1,267 @@ +// The named-area discs and titles of Mapped Installations on the globe +// contract (engine.movers, engine.camera, engine.frames, engine.projector), +// the same on every engine. The bounded cohort is the named areas, largest +// first, with the selected site first: a 7 px disc in its class colour with +// a 1 px black outline, always on top, hidden behind the globe's horizon +// and for a selected site that is not a named area. The shared overlay +// (overlayHost) projects and declutters the titles; a camera move only +// re-tests the discs. + +import * as geo from '../../../geo/index.js'; +import { + MILITARY_LABEL_CAP, + MILITARY_POINT_CAP, +} from '../../../data/militaryNames.js'; +import { createInfrastructureOverlayEntry } from '../../../data/infrastructureOverlayEntry.js'; +import { installationClassLabel, installationTitle } from '../classLabel.js'; +import { LAYER_ID } from '../policy.js'; +import { + MARKER_OUTLINE_PX, + NAMED_MARKER_ORDER, + NAMED_MARKER_OUTLINE_COLOR, + NAMED_MARKER_SIZE_PX, + installationAccent, +} from './style.js'; + +export const NAMED_MARKER_SET_ID = `${LAYER_ID}-named`; + +/** + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {{ state: object, services: object, parts: object, + * overlayHost: object }} context The layer's state, services and parts, + * and the shared overlay that paints the titles. + */ +export function createNamedMarkers( + engine, + { state, services, parts, overlayHost }, +) { + let points = null; + let entries = []; + const markers = new Map(); + let ordered = []; + let shown = false; + let shownPoints = 0; + const screen = geo.cartesian2.create(); + const viewProjection = new Array(16).fill(0); + const lastViewProjection = new Array(16).fill(Number.NaN); + let width = 0, + height = 0; + let removeChanged = null, + removeEnd = null, + removeFrame = null; + + /** Writes each disc's visibility and counts those on screen. */ + function refresh() { + if (!state.enabled || !points) return; + ({ width, height } = engine.host.size()); + engine.projector.viewProjection(viewProjection); + for (let i = 0; i < 16; i++) lastViewProjection[i] = viewProjection[i]; + shownPoints = 0; + let changed = false; + for (let index = 0; index < ordered.length; index++) { + const marker = ordered[index]; + const show = Boolean( + shown && + marker.named && + engine.projector.horizonVisible(marker.view.position), + ); + if (marker.view.show !== show) { + marker.view.show = show; + points.set(index, { show }); + changed = true; + } + if (!show) continue; + const projected = engine.projector.worldToWindow( + marker.view.position, + screen, + ); + if ( + projected && + screen.x >= 0 && + screen.y >= 0 && + screen.x < width && + screen.y < height + ) + shownPoints++; + } + if (changed) points.commit(); + } + + function publishLabels() { + overlayHost.setEntries(LAYER_ID, entries, { + // Keep the bounded point cohort available while Contacts owns the window. + // The arbiter spends at most 24 slots on currently visible, separated titles. + cohortLimit: MILITARY_POINT_CAP, + collisionCapacity: MILITARY_LABEL_CAP, + maxVisible: MILITARY_LABEL_CAP - (state.selectedId ? 1 : 0), + moving: false, + visible: state.enabled, + }); + } + + function cameraChanged() { + refresh(); + services.render.governorRequestRender('installation-names'); + } + + // Small pose changes raise no camera event: test the discs before the + // frame whose view or canvas changed. + function frame() { + engine.projector.viewProjection(viewProjection); + const size = engine.host.size(); + let moved = size.width !== width || size.height !== height; + for (let i = 0; i < 16 && !moved; i++) + moved = viewProjection[i] !== lastViewProjection[i]; + if (moved) refresh(); + } + + function enable() { + if (removeChanged || removeEnd || removeFrame) return; + removeChanged = engine.camera.changed.addEventListener(cameraChanged); + removeEnd = engine.camera.moveEnd.addEventListener(cameraChanged); + removeFrame = engine.frames.preRender.addEventListener(frame); + shown = true; + publishLabels(); + overlayHost.setVisible(LAYER_ID, true); + cameraChanged(); + } + + function hide() { + removeChanged?.(); + removeEnd?.(); + removeFrame?.(); + removeChanged = removeEnd = removeFrame = null; + shown = false; + if (points) { + for (let index = 0; index < ordered.length; index++) { + ordered[index].view.show = false; + points.set(index, { show: false }); + } + points.commit(); + } + overlayHost.clearSource(LAYER_ID); + overlayHost.setVisible(LAYER_ID, false); + shownPoints = 0; + } + + function sync(records) { + points ??= engine.movers.createSet({ + id: NAMED_MARKER_SET_ID, + kind: 'point', + capacity: MILITARY_POINT_CAP, + order: NAMED_MARKER_ORDER, + depthTest: 'always', + pickable: true, + point: { + sizePx: NAMED_MARKER_SIZE_PX, + outlinePx: MARKER_OUTLINE_PX, + outlineColor: NAMED_MARKER_OUTLINE_COLOR, + }, + }); + shown = state.enabled; + const named = records + .filter((r) => r.namedArea || r.id === state.selectedId) + .sort( + (a, b) => + Number(b.id === state.selectedId) - + Number(a.id === state.selectedId) || b.areaM2 - a.areaM2, + ) + .slice(0, MILITARY_POINT_CAP); + const keep = new Set(named.map((r) => r.id)); + // A canonical record takes over its alias's disc and title. + for (const record of named) { + if (markers.has(record.id)) continue; + const alias = record.aliasIds?.find( + (id) => !keep.has(id) && markers.has(id), + ); + if (!alias) continue; + const marker = markers.get(alias); + markers.delete(alias); + marker.view.id = { installationId: record.id }; + marker.entry.id = record.id; + markers.set(record.id, marker); + } + for (const id of [...markers.keys()]) if (!keep.has(id)) markers.delete(id); + ordered = []; + entries = []; + for (const record of named) { + const position = geo.cartesian3.fromDegrees( + record.longitude, + record.latitude, + record.pointOnly ? 0 : parts.model.installationSurfaceHeightM(record), + ); + let marker = markers.get(record.id); + if (!marker) { + marker = { + view: { id: { installationId: record.id }, position, show: false }, + entry: createInfrastructureOverlayEntry({ + id: record.id, + source: LAYER_ID, + position, + title: installationTitle(record.name), + priority: record.areaM2 || 0, + accent: installationAccent(record), + }), + }; + markers.set(record.id, marker); + } + marker.view.position = position; + marker.named = record.namedArea; + marker.entry.variant = + record.id === state.selectedId ? 'selected' : 'card'; + marker.entry.selected = record.id === state.selectedId; + marker.entry.details = marker.entry.selected + ? [ + installationClassLabel(record.class), + ...(record.memberNames || []).slice(0, 3), + ] + : []; + marker.entry.position = position; + marker.entry.title = installationTitle(record.name); + marker.entry.priority = record.areaM2 || 0; + points.set(ordered.length, { + position, + color: parts.model.colorFor(record).rgba(), + pickId: record.id, + show: marker.view.show, + }); + entries.push(marker.entry); + ordered.push(marker); + } + points.setCount(ordered.length); + points.commit(); + if (state.enabled) publishLabels(); + refresh(); + services.render.governorRequestRender('installation-names'); + } + + return { + sync, + /** Each disc: its site id and `{ id, position, show }`. */ + visit(visitor) { + for (let i = 0; i < ordered.length; i++) { + const marker = ordered[i]; + visitor(marker.view.id.installationId, marker.view); + } + }, + refresh, + enable, + hide, + stats: () => ({ + namedMarkers: markers.size, + pointsOnScreen: shownPoints, + labelsOnScreen: state.enabled + ? overlayHost.getDiagnostics?.().paintedBySource[LAYER_ID] || 0 + : 0, + labelCap: MILITARY_LABEL_CAP, + }), + destroy() { + hide(); + ordered = []; + points?.destroy(); + points = null; + entries = []; + markers.clear(); + }, + }; +} diff --git a/src/layers/installations/render/navara.js b/src/layers/installations/render/navara.js deleted file mode 100644 index dd61762d4..000000000 --- a/src/layers/installations/render/navara.js +++ /dev/null @@ -1,638 +0,0 @@ -import { t } from '../../../i18n/index.js'; -import * as geo from '../../../geo/index.js'; -import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js'; -import { createInfrastructureOverlayEntry } from '../../../data/infrastructureOverlayEntry.js'; -import { - MILITARY_LABEL_CAP, - MILITARY_POINT_CAP, -} from '../../../data/militaryNames.js'; -import { installationClassLabel, installationTitle } from '../classLabel.js'; -import { COLOR_BY_CLASS, LAYER_ID, MAX_RENDERED } from '../policy.js'; -import { - createInstallationDrapedMesh, - createInstallationOutlineDrapedMesh, -} from './navaraGeometry.js'; -import { batchInstallationOutlines } from './navaraOutlineBatch.js'; -import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js'; - -const KEY = 'gevInstallationsDrape'; -const BLACK = [0, 0, 0, 0.8]; -const IDLE_SHADER = /* glsl */ ` -void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) { - outputColor=inputColor; -} -`; - -const drapeEffect = defineGroundEffect({ - key: KEY, - name: 'GevInstallationDrape', - fragmentShader: IDLE_SHADER, - insertAfter: ['mrt'], - update() { - const mrt = this.find('mrt'); - this.owner.globeNormal.value = mrt?.globeNormalBuffer ?? null; - this.owner.globeDepth.value = mrt?.globeDepthBuffer ?? null; - const scene = this.ctx.scenes.draped; - this.owner.scene = scene; - for (const mesh of this.owner.meshes) - if (mesh.parent !== scene) scene.add(mesh); - }, - destroy() { - for (const mesh of this.owner.meshes) { - if (mesh.parent) mesh.parent.remove(mesh); - mesh.geometry.dispose(); - mesh.material.dispose(); - } - this.owner.meshes.clear(); - this.owner.scene = null; - }, -}); - -function createPointSet(engine, { id, sizePx, capacity, order }) { - return engine.movers.createSet({ - id, - kind: 'point', - capacity, - order, - depthTest: 'normal', - pickable: true, - point: { sizePx, outlinePx: 1, outlineColor: BLACK }, - }); -} - -export function createInstallationsNavaraRenderer( - engine, - { - state, - services, - parts, - source, - overlayHost, - view, - EffectDesc, - NavaraEffect, - DrapedMesh, - setupMaterialForDrape, - }, -) { - drapeEffect.register(view, { EffectDesc, NavaraEffect }); - - const layerState = state; - const owner = { - meshes: new Set(), - scene: null, - globeNormal: { value: null }, - globeDepth: { value: null }, - }; - const normalSet = createPointSet(engine, { - id: `${LAYER_ID}-normal`, - sizePx: 9, - capacity: MAX_RENDERED + 1, - order: 10, - }); - const selectedSet = createPointSet(engine, { - id: `${LAYER_ID}-selected`, - sizePx: 13, - capacity: 1, - order: 11, - }); - const namedSet = createPointSet(engine, { - id: `${LAYER_ID}-named`, - sizePx: 7, - capacity: MILITARY_POINT_CAP, - order: 9, - }); - const contextObjects = new Map(); - const namedRecords = new Map(); - const selectionMeshes = new Set(); - const outlineMeshes = new Set(); - let outlineRecords = [], - outlineWidthM = null; - let drapeHandle = null; - let visible = false; - let fillRecord = null; - let interactionRemove = null; - let removeContextListeners = null; - let destroyed = false; - - const displayPosition = (record) => - geo.cartesian3.fromDegrees( - record.longitude, - record.latitude, - parts.model.installationSurfaceHeightM(record), - ); - const rgba = (record, selected = false) => { - const color = parts.model.colorFor(record); - return selected - ? [1, 1, 1, 1] - : [color.red, color.green, color.blue, color.alpha]; - }; - const schedule = () => engine.render.request('installations-render'); - - function syncPlacesCredit(records) { - const usesPlaces = records.some((record) => - record.sources?.some((item) => item.name === 'Google Maps Places'), - ); - if (usesPlaces) - services.credits?.showGooglePlacesCredit?.(engine, LAYER_ID); - else services.credits?.hideGooglePlacesCredit?.(engine, LAYER_ID); - } - - function replaceOwnedMeshes(previous, next) { - for (const mesh of previous) { - if (mesh.parent) mesh.parent.remove(mesh); - mesh.geometry.dispose(); - mesh.material.dispose(); - owner.meshes.delete(mesh); - } - previous.clear(); - for (const mesh of next) { - previous.add(mesh); - owner.meshes.add(mesh); - } - syncDrapeEffect(); - } - - function setMeshList(next) { - replaceOwnedMeshes(selectionMeshes, next); - } - - function syncDrapeEffect() { - const show = visible && owner.meshes.size > 0; - if (show && !drapeHandle) - drapeHandle = view.addEffect({ [KEY]: { owner }, visible: true }); - else if (drapeHandle) drapeHandle.update({ visible: show }); - } - - function fillMeshes(record) { - const footprints = - record.footprints || (record.footprint ? [[record.footprint]] : []); - const color = parts.model.colorFor(record).toCssColorString(); - const meshes = []; - for (const rings of footprints) { - const mesh = createInstallationDrapedMesh(record, rings, { - color, - opacity: 0.22, - globeNormal: owner.globeNormal, - globeDepth: owner.globeDepth, - DrapedMesh, - setupMaterialForDrape, - }); - if (mesh) meshes.push(mesh); - } - return meshes; - } - - function clearSelectionFill() { - fillRecord = null; - setMeshList([]); - } - - function syncSelectionFill() { - const record = - layerState.enabled && layerState.recordById.get(layerState.selectedId); - if (record === fillRecord) return; - fillRecord = record ?? null; - setMeshList(record ? fillMeshes(record) : []); - } - - function syncOutlines(records) { - const canvasHeight = Math.max(1, view.canvas?.clientHeight || 1); - const cameraHeight = Math.max(0, engine.camera.height?.() || 0); - const fov = ((view.camera?.raw?.fov || 60) * Math.PI) / 180; - const widthM = Math.max( - 1, - (2 * cameraHeight * Math.tan(fov / 2) * 2) / canvasHeight, - ); - const desired = records.filter( - (record) => !record.pointOnly && !record.namedArea, - ); - if ( - outlineWidthM === widthM && - desired.length === outlineRecords.length && - desired.every((record, index) => record === outlineRecords[index]) - ) - return; - const next = []; - for (const record of desired) { - const lines = - record.outlineLines || (record.footprint ? [record.footprint] : []); - const meshes = lines - .map((line) => - createInstallationOutlineDrapedMesh(record, line, { - color: parts.model.colorFor(record).toCssColorString(), - widthM, - globeNormal: owner.globeNormal, - globeDepth: owner.globeDepth, - DrapedMesh, - setupMaterialForDrape, - }), - ) - .filter(Boolean); - next.push(...meshes); - } - replaceOwnedMeshes(outlineMeshes, batchInstallationOutlines(next)); - outlineRecords = desired; - outlineWidthM = widthM; - } - - function clearOutlines() { - replaceOwnedMeshes(outlineMeshes, []); - outlineRecords = []; - outlineWidthM = null; - } - - function registerContext(record) { - let context = contextObjects.get(record.id); - if (!context) { - context = {}; - contextObjects.set(record.id, context); - } - context.gevContextId = record.id; - context.gevLabelModel = { - title: installationTitle(record.name) || t('MAPPED INSTALLATION'), - details: [ - installationClassLabel(record.class), - ...(record.memberNames || []).slice(0, 3), - ], - accent: COLOR_BY_CLASS[record.class] || '#9ca6b0', - }; - services.context.registerEntityContext(context, { - id: record.id, - layerId: LAYER_ID, - layerName: - record.kind === 'place_candidate' - ? 'Military Site Search Candidates' - : 'Mapped Military Installations', - source: parts.model.installationSourceLabel(record), - label: record.name, - latitude: record.latitude, - longitude: record.longitude, - properties: { - class: record.class, - memberNames: record.memberNames || [], - primaryType: record.primaryType || null, - placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [], - validation: record.validation, - retrievedAt: record.retrievedAt, - }, - }); - return context; - } - - function renderRecords({ claimSelection = false } = {}) { - if (destroyed) return; - const selectedContext = services.context.getSelectedEntityContext(); - if ( - !claimSelection && - layerState.selectedId && - selectedContext?.id !== layerState.selectedId - ) - layerState.selectedId = null; - if ( - layerState.selectedId && - !layerState.recordById.has(layerState.selectedId) - ) { - const replacement = layerState.records.find((record) => - record.aliasIds?.includes(layerState.selectedId), - ); - layerState.selectedId = replacement?.id ?? null; - } - - const records = layerState.records.filter( - (record) => !record.pointOnly && !record.namedArea, - ); - const hasOsm = layerState.records.some((record) => - record.sources?.some((item) => item.name === 'OpenStreetMap'), - ); - if (hasOsm) - services.credits?.showOsmCredit?.(engine, LAYER_ID, { - openMapTiles: layerState.records.some((record) => - record.sources?.some( - (item) => - item.name === 'OpenStreetMap' && item.id?.startsWith('tile:'), - ), - ), - }); - else services.credits?.hideOsmCredit?.(engine, LAYER_ID); - syncPlacesCredit(layerState.records); - const normal = records - .filter((record) => record.id !== layerState.selectedId) - .slice(0, MAX_RENDERED); - const selected = layerState.records.find( - (record) => record.id === layerState.selectedId && !record.pointOnly, - ); - normalSet.setCount(normal.length); - for (let index = 0; index < normal.length; index++) { - const record = normal[index]; - normalSet.set(index, { - position: displayPosition(record), - color: rgba(record), - pickId: record.id, - }); - } - selectedSet.setCount(selected ? 1 : 0); - if (selected) - selectedSet.set(0, { - position: displayPosition(selected), - color: rgba(selected, true), - pickId: selected.id, - }); - normalSet.commit(); - selectedSet.commit(); - - const retainIds = new Set( - [...normal, ...(selected ? [selected] : [])].map((r) => r.id), - ); - for (const record of [...normal, ...(selected ? [selected] : [])]) { - const entity = registerContext(record); - if ( - record.id === layerState.selectedId && - (claimSelection || !selectedContext) - ) - services.context.selectEntityContext(entity); - } - services.context.removeEntityContextsForLayer(LAYER_ID, { retainIds }); - parts.namedMarkers.sync(layerState.records); - syncOutlines([...normal, ...(selected ? [selected] : [])]); - syncSelectionFill(); - schedule(); - } - - function selectRecord(id) { - if (!layerState.recordById.has(id)) return false; - layerState.selectedId = id; - renderRecords({ claimSelection: true }); - return true; - } - - function deselect() { - if (!layerState.selectedId) return; - layerState.selectedId = null; - services.context.clearSelectedEntityContextForLayer(LAYER_ID); - renderRecords(); - } - - const namedMarkers = { - enable() { - namedSet.setCount(namedRecords.size); - overlayHost.setVisible(LAYER_ID, true); - }, - hide() { - namedSet.setCount(0); - namedSet.commit(); - overlayHost.clearSource(LAYER_ID); - overlayHost.setVisible(LAYER_ID, false); - }, - sync(records) { - namedRecords.clear(); - const named = records - .filter((record) => record.namedArea || record.pointOnly) - .sort((a, b) => b.areaM2 - a.areaM2) - .slice(0, MILITARY_POINT_CAP); - const entries = []; - for (let index = 0; index < named.length; index++) { - const record = named[index]; - namedRecords.set(record.id, record); - const position = geo.cartesian3.fromDegrees( - record.longitude, - record.latitude, - record.pointOnly ? 0 : parts.model.installationSurfaceHeightM(record), - ); - namedSet.set(index, { - position, - color: rgba(record), - pickId: record.id, - }); - const entry = createInfrastructureOverlayEntry({ - id: record.id, - source: LAYER_ID, - position, - title: record.name, - priority: record.areaM2 || 0, - accent: COLOR_BY_CLASS[record.class] || '#9ca6b0', - }); - const selected = record.id === layerState.selectedId; - entry.variant = selected ? 'selected' : 'card'; - entry.selected = selected; - entry.details = selected - ? [ - String(record.class || 'installation') - .replaceAll('_', ' ') - .toUpperCase(), - ...(record.memberNames || []).slice(0, 3), - ] - : []; - entries.push(entry); - } - namedSet.setCount(visible ? named.length : 0); - namedSet.commit(); - overlayHost.setEntries(LAYER_ID, entries, { - cohortLimit: MILITARY_POINT_CAP, - collisionCapacity: MILITARY_LABEL_CAP, - maxVisible: MILITARY_LABEL_CAP - (layerState.selectedId ? 1 : 0), - moving: false, - visible, - }); - }, - visit(visitor) { - for (const record of namedRecords.values()) - visitor(record.id, { - position: geo.cartesian3.fromDegrees( - record.longitude, - record.latitude, - ), - }); - }, - stats: () => ({ - namedMarkers: namedRecords.size, - pointsOnScreen: 0, - labelsOnScreen: 0, - labelCap: MILITARY_LABEL_CAP, - }), - destroy() { - this.hide(); - namedRecords.clear(); - namedSet.destroy(); - }, - }; - - const rendering = { - installationSurfaceHeightM: parts.model.installationSurfaceHeightM, - clearSelectionFill, - syncSelectionFill, - clearRendered() { - normalSet.setCount(0); - selectedSet.setCount(0); - normalSet.commit(); - selectedSet.commit(); - clearOutlines(); - clearSelectionFill(); - services.context.removeEntityContextsForLayer(LAYER_ID); - contextObjects.clear(); - }, - renderableRecords() { - const records = layerState.records - .filter((record) => !record.pointOnly && !record.namedArea) - .slice(0, MAX_RENDERED); - const selected = layerState.recordById.get(layerState.selectedId); - return selected && !records.some((record) => record.id === selected.id) - ? [...records, selected] - : records; - }, - renderRecords, - warmInstallationFloors(records) { - const cold = records - .filter( - (record) => - services.ground.cachedGroundFloor( - record.latitude, - record.longitude, - ) == null, - ) - .map((record) => ({ lat: record.latitude, lon: record.longitude })); - if (!cold.length) return; - services.anchors.warmFireAnchorFloors(cold).then(() => { - if (!layerState.enabled || destroyed) return; - if ( - cold.some( - (point) => - services.ground.cachedGroundFloor(point.lat, point.lon) != null, - ) - ) - renderRecords(); - }); - }, - }; - - const selection = { - selectRecord, - installInteraction() { - if (interactionRemove) return; - const dispatcher = selectionDispatcherFor(engine); - if (!dispatcher) - throw new Error('Installation selection dispatcher unavailable'); - interactionRemove = dispatcher.register(LAYER_ID, { - owns: (id) => layerState.recordById.has(id), - onSelect: selectRecord, - onEmptyClick: deselect, - }); - layerState.clickHandler = { destroy: interactionRemove }; - const events = [ - 'gev:entity-selected', - 'gev:entity-selection-cleared', - 'gev:awareness-subject-selected', - ]; - const sync = (event) => { - if (!layerState.enabled) return; - const selected = - event?.type === 'gev:awareness-subject-selected' - ? event.detail - : services.context.getSelectedEntityContext(); - const next = - selected?.layerId === LAYER_ID && - layerState.recordById.has(selected.id) - ? selected.id - : null; - if (next === layerState.selectedId) return; - layerState.selectedId = next; - renderRecords(); - }; - for (const event of events) - globalThis.window?.addEventListener?.(event, sync); - removeContextListeners = () => { - for (const event of events) - globalThis.window?.removeEventListener?.(event, sync); - }; - }, - destroy() { - interactionRemove?.(); - interactionRemove = null; - removeContextListeners?.(); - removeContextListeners = null; - }, - }; - - const lifecycle = { - methods: { - init(viewer, { engine: initEngine = engine } = {}) { - layerState.viewer = viewer; - layerState.engine = initEngine; - const cameraChanged = () => { - if (!layerState.contextAnchor) parts.viewport.scheduleLoad(); - }; - layerState.moveEndRemove = - engine.camera.moveEnd.addEventListener(cameraChanged); - layerState.changedRemove = - engine.camera.changed.addEventListener(cameraChanged); - selection.installInteraction(engine); - }, - enable() { - visible = true; - layerState.enabled = true; - for (const set of [normalSet, selectedSet]) set.setCount(0); - normalSet.commit(); - selectedSet.commit(); - namedMarkers.enable(); - namedMarkers.sync(layerState.records || []); - services.picking.registerPickOwner(LAYER_ID, (id) => - layerState.recordById.has(id), - ); - renderRecords({ claimSelection: Boolean(layerState.selectedId) }); - }, - disable() { - visible = false; - layerState.enabled = false; - services.credits?.hideOsmCredit?.(engine, LAYER_ID); - syncPlacesCredit([]); - namedMarkers.hide(); - layerState.contextAnchor = null; - layerState.contextPosition = null; - layerState.coverage = { kind: 'viewport' }; - layerState.cameraLoadKey = null; - layerState.cameraLoadOwner = null; - services.picking.unregisterPickOwner(LAYER_ID); - parts.viewport.clearUnavailableRetry(); - clearTimeout(layerState.timer); - layerState.abort?.abort(); - layerState.abort = null; - layerState.loading = false; - normalSet.setCount(0); - selectedSet.setCount(0); - normalSet.commit(); - selectedSet.commit(); - clearOutlines(); - clearSelectionFill(); - services.context.clearSelectedEntityContextForLayer(LAYER_ID); - layerState.selectedId = null; - parts.ingestion.setInstallationStatus('idle'); - }, - destroy() { - source.destroy?.(); - this.disable(); - this.detach(); - }, - detach() { - services.credits?.hideOsmCredit?.(engine, LAYER_ID); - syncPlacesCredit([]); - services.picking.unregisterPickOwner(LAYER_ID); - layerState.moveEndRemove?.(); - layerState.changedRemove?.(); - layerState.moveEndRemove = layerState.changedRemove = null; - selection.destroy(); - rendering.clearRendered(); - namedMarkers.destroy(); - drapeHandle?.delete(); - drapeHandle = null; - normalSet.destroy(); - selectedSet.destroy(); - owner.scene = null; - layerState.viewer = null; - layerState.engine = null; - destroyed = true; - }, - }, - }; - - return { lifecycle, namedMarkers, rendering, selection }; -} diff --git a/src/layers/installations/render/navara.test.mjs b/src/layers/installations/render/navara.test.mjs deleted file mode 100644 index 9c92a1855..000000000 --- a/src/layers/installations/render/navara.test.mjs +++ /dev/null @@ -1,240 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { Mesh } from 'three'; -import { setLocale } from '../../../i18n/index.js'; -import { installationClassLabel, installationTitle } from '../classLabel.js'; -import { createInstallationsNavaraRenderer } from './navara.js'; - -test('selected installation fill is reused and removed with its scene resources', () => { - const record = { - id: 'osm:way:12', - longitude: 179.9, - latitude: 70, - class: 'military_land', - footprint: [ - [179.88, 69.99], - [179.92, 69.99], - [179.92, 70.01], - [179.88, 70.01], - ], - }; - const state = { - enabled: true, - records: [record], - recordById: new Map([[record.id, record]]), - selectedId: null, - }; - const color = { - red: 0.6, - green: 0.6, - blue: 0.6, - alpha: 1, - toCssColorString: () => '#9ca6b0', - }; - const contexts = new Map(); - let selectedContext = null; - const services = { - context: { - getSelectedEntityContext: () => selectedContext, - registerEntityContext: (entity, value) => contexts.set(value.id, entity), - selectEntityContext: (entity) => { - selectedContext = { - id: entity.gevContextId, - layerId: 'military-installations', - }; - }, - removeEntityContextsForLayer() {}, - clearSelectedEntityContextForLayer() {}, - }, - picking: { registerPickOwner() {}, unregisterPickOwner() {} }, - }; - const sets = []; - const engine = { - movers: { - createSet(options) { - const set = { - options, - setCount() {}, - set() {}, - commit() {}, - destroy() {}, - }; - sets.push(set); - return set; - }, - }, - camera: { height: () => 1_000 }, - render: { request() {} }, - }; - let effectConfig; - let deletedEffects = 0; - const view = { - canvas: { clientHeight: 800 }, - camera: { raw: { fov: 60 } }, - registerEffect() {}, - addEffect(config) { - effectConfig = config; - return { - update() {}, - delete() { - deletedEffects++; - }, - }; - }, - }; - let meshCount = 0; - class TestDrapedMesh extends Mesh { - constructor(geometry, material) { - super(geometry, material); - meshCount++; - } - } - class TestEffectDesc {} - const parts = { - model: { - colorFor: () => color, - installationSurfaceHeightM: () => 0, - installationSourceLabel: () => 'OpenStreetMap', - }, - namedMarkers: { sync() {}, enable() {}, hide() {}, destroy() {} }, - viewport: { clearUnavailableRetry() {} }, - ingestion: { setInstallationStatus() {} }, - }; - const renderer = createInstallationsNavaraRenderer(engine, { - state, - services, - parts, - source: {}, - overlayHost: { - setVisible() {}, - setEntries() {}, - clearSource() {}, - }, - view, - EffectDesc: TestEffectDesc, - NavaraEffect: class {}, - DrapedMesh: TestDrapedMesh, - setupMaterialForDrape() {}, - }); - - renderer.lifecycle.methods.enable(); - assert.equal( - meshCount, - 1, - 'the ordinary outline is present before selection', - ); - assert.equal(renderer.selection.selectRecord(record.id), true); - assert.equal(meshCount, 2, 'selection adds one fill beside the outline'); - const mesh = [...effectConfig.gevInstallationsDrape.owner.meshes].find( - (item) => !item.userData.installationOutline, - ); - let disposed = false; - mesh.geometry.addEventListener('dispose', () => { - disposed = true; - }); - const scene = { - add(child) { - child.parent = this; - }, - remove(child) { - child.parent = null; - }, - }; - scene.add(mesh); - renderer.rendering.renderRecords(); - assert.equal(meshCount, 2, 'an unchanged selection keeps its geometry'); - - renderer.rendering.clearSelectionFill(); - assert.equal(mesh.parent, null); - assert.equal(disposed, true); - assert.equal( - [...effectConfig.gevInstallationsDrape.owner.meshes].some( - (item) => item.userData.installationOutline, - ), - true, - 'clearing selection preserves the ordinary outline', - ); - assert.equal(sets.length, 3); - renderer.lifecycle.methods.destroy(); - assert.equal(deletedEffects, 1, 'destroy deletes the public effect handle'); - assert.equal(effectConfig.gevInstallationsDrape.owner.meshes.size, 0); -}); - -test('the card label reads in the UI language, as on Cesium', () => { - const record = { - id: 'osm:node:4', - longitude: 10, - latitude: 50, - class: 'naval_base', - name: 'Firing range', - }; - const state = { - enabled: true, - records: [record], - recordById: new Map([[record.id, record]]), - selectedId: null, - }; - const contexts = new Map(); - const set = { setCount() {}, set() {}, commit() {}, destroy() {} }; - const renderer = createInstallationsNavaraRenderer( - { - movers: { createSet: () => set }, - camera: { height: () => 1_000 }, - render: { request() {} }, - }, - { - state, - services: { - context: { - getSelectedEntityContext: () => null, - registerEntityContext: (entity, value) => - contexts.set(value.id, entity), - selectEntityContext() {}, - removeEntityContextsForLayer() {}, - clearSelectedEntityContextForLayer() {}, - }, - picking: { registerPickOwner() {}, unregisterPickOwner() {} }, - }, - parts: { - model: { - colorFor: () => ({ - red: 0.6, - green: 0.6, - blue: 0.6, - alpha: 1, - toCssColorString: () => '#9ca6b0', - }), - installationSurfaceHeightM: () => 0, - installationSourceLabel: () => 'OpenStreetMap', - }, - namedMarkers: { sync() {}, enable() {}, hide() {}, destroy() {} }, - viewport: { clearUnavailableRetry() {} }, - ingestion: { setInstallationStatus() {} }, - }, - source: {}, - overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, - view: { - canvas: { clientHeight: 800 }, - camera: { raw: { fov: 60 } }, - registerEffect() {}, - addEffect: () => ({ update() {}, delete() {} }), - }, - EffectDesc: class {}, - NavaraEffect: class {}, - DrapedMesh: Mesh, - setupMaterialForDrape() {}, - }, - ); - try { - setLocale('ru'); - renderer.lifecycle.methods.enable(); - const label = contexts.get(record.id).gevLabelModel; - assert.equal(label.title, installationTitle('Firing range')); - assert.notEqual(label.title, 'Firing range'); - assert.equal(label.details[0], installationClassLabel('naval_base')); - assert.notEqual(label.details[0], 'NAVAL BASE'); - } finally { - setLocale('en'); - renderer.lifecycle.methods.destroy(); - } -}); diff --git a/src/layers/installations/render/navaraGeometry.js b/src/layers/installations/render/navaraGeometry.js deleted file mode 100644 index 4a11bcf98..000000000 --- a/src/layers/installations/render/navaraGeometry.js +++ /dev/null @@ -1,201 +0,0 @@ -import { - DoubleSide, - ExtrudeGeometry, - MeshLambertMaterial, - Path, - Shape, -} from 'three'; -import * as geo from '../../../geo/index.js'; -import { batchStencilDraws } from './navaraStencilBatch.js'; - -export const INSTALLATION_DRAPE_DEPTH_M = 40000; - -function localPoint([longitude, latitude], origin, axes) { - const point = geo.cartesian3.fromDegrees(longitude, latitude); - const delta = geo.cartesian3.subtract(point, origin, geo.cartesian3.create()); - return { - x: geo.cartesian3.dot(delta, axes.east), - y: geo.cartesian3.dot(delta, axes.north), - }; -} - -function addRing(path, ring, origin, axes) { - const points = ring.map((point) => localPoint(point, origin, axes)); - if (points.length < 3) return false; - path.moveTo(points[0].x, points[0].y); - for (const point of points.slice(1)) path.lineTo(point.x, point.y); - path.closePath(); - return true; -} - -function offsetLine(line, halfWidth, origin, axes) { - const points = []; - for (const point of line) { - const local = localPoint(point, origin, axes); - const previous = points.at(-1); - if ( - !previous || - Math.hypot(local.x - previous.x, local.y - previous.y) >= 0.01 - ) - points.push(local); - } - const closed = - points.length > 2 && - Math.hypot(points[0].x - points.at(-1).x, points[0].y - points.at(-1).y) < - 1; - if (closed) points.pop(); - if (points.length < 2) return null; - - const side = (index, direction) => { - const previous = points[index === 0 ? points.length - 1 : index - 1]; - const current = points[index]; - const next = points[index === points.length - 1 ? 0 : index + 1]; - const before = { x: current.x - previous.x, y: current.y - previous.y }; - const after = { x: next.x - current.x, y: next.y - current.y }; - const beforeLength = Math.hypot(before.x, before.y); - const afterLength = Math.hypot(after.x, after.y); - const beforeNormal = { - x: -before.y / beforeLength, - y: before.x / beforeLength, - }; - const afterNormal = { x: -after.y / afterLength, y: after.x / afterLength }; - if (!closed && (index === 0 || index === points.length - 1)) { - const normal = index === 0 ? afterNormal : beforeNormal; - return { - x: current.x + normal.x * halfWidth * direction, - y: current.y + normal.y * halfWidth * direction, - }; - } - const miter = { - x: beforeNormal.x + afterNormal.x, - y: beforeNormal.y + afterNormal.y, - }; - const length = Math.hypot(miter.x, miter.y); - if (length < 1e-6) - return { - x: current.x + afterNormal.x * halfWidth * direction, - y: current.y + afterNormal.y * halfWidth * direction, - }; - miter.x /= length; - miter.y /= length; - const denominator = miter.x * afterNormal.x + miter.y * afterNormal.y; - const distance = - halfWidth * Math.min(4, 1 / Math.max(0.25, Math.abs(denominator))); - return { - x: current.x + miter.x * distance * direction, - y: current.y + miter.y * distance * direction, - }; - }; - - const outer = points.map((_, index) => side(index, 1)); - if (!closed) { - const inner = points.map((_, index) => side(index, -1)).reverse(); - return [...outer, ...inner]; - } - return { outer, inner: points.map((_, index) => side(index, -1)) }; -} - -/** Build a real DrapedMesh volume for one footprint, including its holes. */ -export function createInstallationDrapedMesh( - record, - rings, - { - color, - opacity = 0.22, - globeNormal, - globeDepth, - DrapedMesh, - setupMaterialForDrape, - }, -) { - const origin = geo.cartesian3.fromDegrees(record.longitude, record.latitude); - const frame = geo.transforms.eastNorthUpToFixedFrame(origin); - const axes = { - east: geo.cartesian3.create(frame[0], frame[1], frame[2]), - north: geo.cartesian3.create(frame[4], frame[5], frame[6]), - }; - const shape = new Shape(); - if (!addRing(shape, rings[0] || [], origin, axes)) return null; - for (const ring of rings.slice(1)) { - const hole = new Path(); - if (addRing(hole, ring, origin, axes)) shape.holes.push(hole); - } - - const geometry = new ExtrudeGeometry(shape, { - depth: INSTALLATION_DRAPE_DEPTH_M, - bevelEnabled: false, - steps: 1, - curveSegments: 1, - }); - geometry.translate(0, 0, -INSTALLATION_DRAPE_DEPTH_M / 2); - const material = new MeshLambertMaterial({ - color, - opacity, - transparent: opacity < 1, - depthWrite: false, - side: DoubleSide, - }); - setupMaterialForDrape?.(material, globeNormal, globeDepth); - const mesh = batchStencilDraws(new DrapedMesh(geometry, material)); - mesh.matrix.fromArray(frame); - mesh.matrixAutoUpdate = false; - mesh.matrixWorldNeedsUpdate = true; - mesh.userData.installationId = record.id; - return mesh; -} - -/** Build a ground-classified, fixed-world-width ribbon around a native outline. */ -export function createInstallationOutlineDrapedMesh( - record, - line, - { color, widthM, globeNormal, globeDepth, DrapedMesh, setupMaterialForDrape }, -) { - const origin = geo.cartesian3.fromDegrees(record.longitude, record.latitude); - const frame = geo.transforms.eastNorthUpToFixedFrame(origin); - const axes = { - east: geo.cartesian3.create(frame[0], frame[1], frame[2]), - north: geo.cartesian3.create(frame[4], frame[5], frame[6]), - }; - const outline = offsetLine(line, Math.max(1, widthM / 2), origin, axes); - if (!outline) return null; - - const shape = new Shape(); - const addLocalRing = (path, points) => { - if (points.length < 3) return false; - path.moveTo(points[0].x, points[0].y); - for (const point of points.slice(1)) path.lineTo(point.x, point.y); - path.closePath(); - return true; - }; - if (Array.isArray(outline)) { - if (!addLocalRing(shape, outline)) return null; - } else { - if (!addLocalRing(shape, outline.outer)) return null; - const hole = new Path(); - if (addLocalRing(hole, outline.inner.slice().reverse())) - shape.holes.push(hole); - } - - const geometry = new ExtrudeGeometry(shape, { - depth: INSTALLATION_DRAPE_DEPTH_M, - bevelEnabled: false, - steps: 1, - curveSegments: 1, - }); - geometry.translate(0, 0, -INSTALLATION_DRAPE_DEPTH_M / 2); - const material = new MeshLambertMaterial({ - color, - opacity: 0.85, - transparent: true, - depthWrite: false, - side: DoubleSide, - }); - setupMaterialForDrape?.(material, globeNormal, globeDepth); - const mesh = batchStencilDraws(new DrapedMesh(geometry, material)); - mesh.matrix.fromArray(frame); - mesh.matrixAutoUpdate = false; - mesh.matrixWorldNeedsUpdate = true; - mesh.userData.installationId = record.id; - mesh.userData.installationOutline = true; - return mesh; -} diff --git a/src/layers/installations/render/navaraGeometry.test.mjs b/src/layers/installations/render/navaraGeometry.test.mjs deleted file mode 100644 index 203cad5b0..000000000 --- a/src/layers/installations/render/navaraGeometry.test.mjs +++ /dev/null @@ -1,136 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { Mesh, MeshLambertMaterial } from 'three'; -import * as geo from '../../../geo/index.js'; -import { - createInstallationDrapedMesh, - createInstallationOutlineDrapedMesh, - INSTALLATION_DRAPE_DEPTH_M, -} from './navaraGeometry.js'; - -const record = { - id: 'osm:way:12', - longitude: 179.9, - latitude: 70, -}; -const texture = { value: null }; -class TestDrapedMesh extends Mesh { - constructor(geometry, material) { - super(geometry, material); - this.drapedEnable = true; - } -} - -test('installation footprint becomes an engine DrapedMesh with hole and ground refs', () => { - const mesh = createInstallationDrapedMesh( - record, - [ - [ - [179.88, 69.99], - [179.92, 69.99], - [179.92, 70.01], - [179.88, 70.01], - ], - [ - [179.895, 69.995], - [179.905, 69.995], - [179.905, 70.005], - [179.895, 70.005], - ], - ], - { - color: '#9ca6b0', - opacity: 0.22, - globeNormal: texture, - globeDepth: texture, - DrapedMesh: TestDrapedMesh, - setupMaterialForDrape: (material, normal, depth) => { - material.userData.drapeRefs = [normal, depth]; - }, - }, - ); - - assert.ok(mesh instanceof TestDrapedMesh); - assert.ok(mesh.material instanceof MeshLambertMaterial); - assert.equal(mesh.userData.installationId, record.id); - assert.equal(mesh.geometry.parameters.shapes.holes.length, 1); - mesh.geometry.computeBoundingBox(); - assert.equal( - mesh.geometry.boundingBox.max.z - mesh.geometry.boundingBox.min.z, - INSTALLATION_DRAPE_DEPTH_M, - ); - assert.deepEqual(mesh.material.userData.drapeRefs, [texture, texture]); - assert.equal(mesh.matrixAutoUpdate, false); - assert.deepEqual( - [ - mesh.matrix.elements[12], - mesh.matrix.elements[13], - mesh.matrix.elements[14], - ], - Object.values( - geo.cartesian3.fromDegrees(record.longitude, record.latitude), - ), - ); - mesh.geometry.dispose(); - mesh.material.dispose(); -}); - -test('invalid installation footprint rings do not allocate a mesh', () => { - assert.equal( - createInstallationDrapedMesh( - record, - [ - [ - [0, 0], - [1, 1], - ], - ], - { - color: '#9ca6b0', - globeNormal: texture, - globeDepth: texture, - DrapedMesh: TestDrapedMesh, - setupMaterialForDrape() {}, - }, - ), - null, - ); -}); - -test('ordinary installation outlines are draped ribbons across the date line', () => { - const mesh = createInstallationOutlineDrapedMesh( - record, - [ - [179.88, 69.99], - [-179.92, 69.99], - [-179.92, 70.01], - [179.88, 70.01], - [179.88, 69.99], - ], - { - color: '#9ca6b0', - widthM: 12, - globeNormal: texture, - globeDepth: texture, - DrapedMesh: TestDrapedMesh, - setupMaterialForDrape: (material, normal, depth) => { - material.userData.drapeRefs = [normal, depth]; - }, - }, - ); - - assert.ok(mesh instanceof TestDrapedMesh); - assert.equal(mesh.userData.installationOutline, true); - assert.equal(mesh.geometry.parameters.shapes.holes.length, 1); - mesh.geometry.computeBoundingBox(); - assert.ok( - mesh.geometry.boundingBox.max.x - mesh.geometry.boundingBox.min.x < 20_000, - ); - assert.equal( - mesh.geometry.boundingBox.max.z - mesh.geometry.boundingBox.min.z, - INSTALLATION_DRAPE_DEPTH_M, - ); - assert.deepEqual(mesh.material.userData.drapeRefs, [texture, texture]); - mesh.geometry.dispose(); - mesh.material.dispose(); -}); diff --git a/src/layers/installations/render/navaraOutlineBatch.js b/src/layers/installations/render/navaraOutlineBatch.js deleted file mode 100644 index 6da5aff91..000000000 --- a/src/layers/installations/render/navaraOutlineBatch.js +++ /dev/null @@ -1,63 +0,0 @@ -import { Matrix4 } from 'three'; -import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; - -// Each DrapedMesh needs three stencil draws. Consecutive, equally coloured -// volumes with disjoint XY bounds can share those draws without changing -// translucent overlap order. Keep coordinates local to preserve precision. -// Consumes the input meshes; the returned meshes own the surviving resources. -export function batchInstallationOutlines(meshes) { - const groups = []; - for (const mesh of meshes) { - const group = groups.at(-1); - let geometry; - if ( - group && - mesh.material.color.equals(group.mesh.material.color) && - mesh.material.opacity === group.mesh.material.opacity - ) { - const relative = new Matrix4().multiplyMatrices( - group.inverse, - mesh.matrix, - ); - if (Math.hypot(...relative.elements.slice(12, 15)) <= 100_000) { - geometry = mesh.geometry.clone().applyMatrix4(relative); - geometry.computeBoundingBox(); - const b = geometry.boundingBox; - if ( - group.bounds.every( - (a) => - b.min.x > a.max.x || - b.max.x < a.min.x || - b.min.y > a.max.y || - b.max.y < a.min.y, - ) - ) { - group.geometries.push(geometry); - group.bounds.push(b); - group.mesh.userData.installationIds.push( - mesh.userData.installationId, - ); - mesh.geometry.dispose(); - mesh.material.dispose(); - continue; - } - geometry.dispose(); - } - } - mesh.geometry.computeBoundingBox(); - mesh.userData.installationIds = [mesh.userData.installationId]; - groups.push({ - mesh, - inverse: mesh.matrix.clone().invert(), - geometries: [mesh.geometry], - bounds: [mesh.geometry.boundingBox], - }); - } - return groups.map(({ mesh, geometries }) => { - if (geometries.length > 1) { - mesh.geometry = mergeGeometries(geometries); - for (const geometry of geometries) geometry.dispose(); - } - return mesh; - }); -} diff --git a/src/layers/installations/render/navaraOutlineBatch.test.mjs b/src/layers/installations/render/navaraOutlineBatch.test.mjs deleted file mode 100644 index 48f61db73..000000000 --- a/src/layers/installations/render/navaraOutlineBatch.test.mjs +++ /dev/null @@ -1,97 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { BoxGeometry, Mesh, MeshBasicMaterial, Vector3 } from 'three'; -import { batchInstallationOutlines } from './navaraOutlineBatch.js'; - -function volume(id, x, { color = '#ff8800', opacity = 0.85, y = 0 } = {}) { - const mesh = new Mesh( - new BoxGeometry(10, 10, 40000), - new MeshBasicMaterial({ color, opacity }), - ); - mesh.matrix.makeTranslation(x, y, 0); - mesh.userData.installationId = id; - return mesh; -} - -test('disjoint equal-colour outlines share a stencil volume without moving their vertices', () => { - const meshes = [ - volume('a', 6_000_000), - volume('b', 6_000_100), - volume('c', 6_000_200), - ]; - const positions = meshes.flatMap((m) => { - const a = m.geometry.attributes.position; - return Array.from({ length: a.count }, (_, i) => - new Vector3().fromBufferAttribute(a, i).applyMatrix4(m.matrix), - ); - }); - const geometryDisposals = [], - materialDisposals = []; - for (const m of meshes) { - m.geometry.addEventListener('dispose', () => - geometryDisposals.push(m.userData.installationId), - ); - m.material.addEventListener('dispose', () => - materialDisposals.push(m.userData.installationId), - ); - } - const result = batchInstallationOutlines(meshes); - assert.equal(result.length, 1); - assert.deepEqual(result[0].userData.installationIds, ['a', 'b', 'c']); - const a = result[0].geometry.attributes.position; - assert.equal(a.count, positions.length); - for (let i = 0; i < a.count; i++) - assert.ok( - new Vector3() - .fromBufferAttribute(a, i) - .applyMatrix4(result[0].matrix) - .distanceTo(positions[i]) < 1e-5, - ); - assert.deepEqual(geometryDisposals.sort(), ['a', 'b', 'c']); - assert.deepEqual(materialDisposals.sort(), ['b', 'c']); - result[0].geometry.dispose(); - result[0].material.dispose(); - assert.deepEqual(materialDisposals.sort(), ['a', 'b', 'c']); -}); - -test('overlapping volumes, different materials and distant origins retain separate draws and order', () => { - for (const second of [ - volume('b', 5), - volume('b', 100, { color: '#00ff00' }), - volume('b', 100, { opacity: 0.5 }), - volume('b', 100_001), - ]) { - const first = volume('a', 0), - result = batchInstallationOutlines([first, second]); - assert.deepEqual(result, [first, second]); - for (const mesh of result) { - mesh.geometry.dispose(); - mesh.material.dispose(); - } - } - const meshes = [ - volume('a', 0), - volume('b', 100, { color: '#00ff00' }), - volume('c', 200), - ]; - assert.deepEqual(batchInstallationOutlines(meshes), meshes); - for (const mesh of meshes) { - mesh.geometry.dispose(); - mesh.material.dispose(); - } -}); - -test('batch admission compares against every prior volume, including rotated bounds', () => { - const meshes = [volume('a', 0), volume('b', 100), volume('c', 0)]; - meshes[2].matrix.makeRotationZ(Math.PI / 4); - const result = batchInstallationOutlines(meshes); - assert.equal(result.length, 2); - assert.deepEqual( - result.map((m) => m.userData.installationIds), - [['a', 'b'], ['c']], - ); - for (const mesh of result) { - mesh.geometry.dispose(); - mesh.material.dispose(); - } -}); diff --git a/src/layers/installations/render/navaraStencilBatch.js b/src/layers/installations/render/navaraStencilBatch.js deleted file mode 100644 index 6b1210bbe..000000000 --- a/src/layers/installations/render/navaraStencilBatch.js +++ /dev/null @@ -1,85 +0,0 @@ -import { Material, Mesh } from 'three'; - -const stateKeys = [ - 'side', - 'colorWrite', - 'depthWrite', - 'depthTest', - 'stencilWrite', - 'stencilFunc', - 'stencilRef', - 'stencilFuncMask', - 'stencilWriteMask', - 'stencilFail', - 'stencilZFail', - 'stencilZPass', -]; - -// Keep Navara's three stencil draws, but submit them together. Three resolves -// the entire multisampled MRT after each render(), even for colorWrite=false. -// Use the public DrapedMesh.process callback to obtain the SDK's exact states. -export function batchStencilDraws(mesh) { - const process = mesh.process; - if (!process || Array.isArray(mesh.material)) return mesh; - const source = mesh.material; - let stages = null, - hidden = null, - version = -1, - disposed = false; - const dispose = () => { - disposed = true; - source.removeEventListener('dispose', dispose); - for (const stage of stages ?? []) stage.material.dispose(); - hidden?.dispose(); - stages = null; - }; - source.addEventListener('dispose', dispose); - mesh.process = function (render) { - if (disposed) return; - if (this.material !== source || this.children.length) - return process.call(this, render); - let index = 0; - process.call(this, () => { - if (!stages) { - hidden = new Material(); - hidden.visible = false; - stages = Array.from({ length: 3 }, (_, order) => { - const stage = new Mesh(this.geometry, source.clone()); - stage.renderOrder = order; - return stage; - }); - } - const stage = stages[index++]; - if (!stage) return; - const material = stage.material; - if (version !== source.version) { - material.copy(source); - material.defines = { ...source.defines }; - material.onBeforeCompile = source.onBeforeCompile; - material.customProgramCacheKey = source.customProgramCacheKey; - material.needsUpdate = true; - } - for (const key of stateKeys) material[key] = source[key]; - material.color = source.color; - material.emissive = source.emissive; - material.opacity = source.opacity; - material.visible = source.visible; - material.transparent = source.transparent; - stage.geometry = this.geometry; - stage.frustumCulled = this.frustumCulled; - stage.layers.mask = this.layers.mask; - }); - if (index === 0) return; - if (index !== 3) return process.call(this, render); - version = source.version; - this.material = hidden; - this.add(...stages); - try { - render(); - } finally { - this.remove(...stages); - this.material = source; - } - }; - return mesh; -} diff --git a/src/layers/installations/render/navaraStencilBatch.test.mjs b/src/layers/installations/render/navaraStencilBatch.test.mjs deleted file mode 100644 index 706703af3..000000000 --- a/src/layers/installations/render/navaraStencilBatch.test.mjs +++ /dev/null @@ -1,212 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - AlwaysStencilFunc, - BackSide, - FrontSide, - KeepStencilOp, - IncrementWrapStencilOp, - DecrementWrapStencilOp, - NotEqualStencilFunc, - ZeroStencilOp, - BufferGeometry, - Mesh, - MeshLambertMaterial, - Group, -} from 'three'; -import { batchStencilDraws } from './navaraStencilBatch.js'; - -const keys = [ - 'side', - 'colorWrite', - 'depthWrite', - 'depthTest', - 'stencilWrite', - 'stencilFunc', - 'stencilRef', - 'stencilFuncMask', - 'stencilWriteMask', - 'stencilFail', - 'stencilZFail', - 'stencilZPass', -]; -const snapshot = (m) => Object.fromEntries(keys.map((k) => [k, m[k]])); -class Drape extends Mesh { - enabled() { - return this.visible; - } - process(render) { - if (!this.enabled()) return; - const m = this.material, - saved = snapshot(m); - try { - Object.assign(m, { - side: BackSide, - colorWrite: false, - depthWrite: false, - depthTest: true, - stencilWrite: true, - stencilFunc: AlwaysStencilFunc, - stencilFail: KeepStencilOp, - stencilZFail: IncrementWrapStencilOp, - stencilZPass: KeepStencilOp, - }); - render(); - m.side = FrontSide; - m.stencilZFail = DecrementWrapStencilOp; - render(); - Object.assign(m, { - side: BackSide, - colorWrite: true, - depthTest: false, - stencilFunc: NotEqualStencilFunc, - stencilFail: ZeroStencilOp, - stencilZFail: ZeroStencilOp, - stencilZPass: ZeroStencilOp, - }); - render(); - } finally { - Object.assign(m, saved); - } - } -} -function fixture() { - const material = new MeshLambertMaterial({ - color: 0x74aaff, - opacity: 0.85, - transparent: true, - }); - material.defines = { NVR_TEST: 1 }; - material.onBeforeCompile = (shader) => { - shader.uniforms.shared = { value: 123 }; - }; - material.customProgramCacheKey = () => 'native-drape'; - return new Drape(new BufferGeometry(), material); -} - -test('three native stencil states render in one submission with the same geometry, transforms and shader hooks', () => { - const mesh = fixture(), - material = mesh.material, - expected = []; - mesh.process(() => expected.push(snapshot(material))); - mesh.position.set(100, 200, 300); - mesh.layers.set(2); - batchStencilDraws(mesh); - let calls = 0; - mesh.process(() => { - calls++; - assert.equal(mesh.material.visible, false); - mesh.updateMatrixWorld(true); - assert.deepEqual( - mesh.children.map((m) => snapshot(m.material)), - expected, - ); - for (const [i, stage] of mesh.children.entries()) { - assert.equal(stage.renderOrder, i); - assert.equal(stage.geometry, mesh.geometry); - assert.deepEqual(stage.matrixWorld.elements, mesh.matrixWorld.elements); - assert.equal(stage.layers.mask, mesh.layers.mask); - assert.equal(stage.material.onBeforeCompile, material.onBeforeCompile); - assert.equal( - stage.material.customProgramCacheKey(), - material.customProgramCacheKey(), - ); - assert.deepEqual(stage.material.defines, material.defines); - } - }); - assert.equal(calls, 1); - assert.equal(mesh.material, material); - assert.equal(mesh.children.length, 0); - mesh.geometry.dispose(); - material.dispose(); -}); - -test('stages track replaced batch geometry, shader versions and live colour/opacity without recompiling steady frames', () => { - const mesh = batchStencilDraws(fixture()), - source = mesh.material; - let previous, versions; - mesh.process(() => { - previous = mesh.children.slice(); - versions = previous.map((s) => s.material.version); - }); - const old = mesh.geometry; - mesh.geometry = new BufferGeometry(); - old.dispose(); - source.color.setHex(0xffcc00); - source.opacity = 0.22; - source.stencilRef = 2; - source.stencilFuncMask = 0x0f; - source.stencilWriteMask = 0x0f; - mesh.process(() => { - assert.deepEqual(mesh.children, previous); - assert.deepEqual( - mesh.children.map((s) => s.material.version), - versions, - ); - for (const s of mesh.children) { - assert.equal(s.geometry, mesh.geometry); - assert.equal(s.material.color, source.color); - assert.equal(s.material.opacity, 0.22); - assert.equal(s.material.stencilRef, 2); - assert.equal(s.material.stencilFuncMask, 0x0f); - assert.equal(s.material.stencilWriteMask, 0x0f); - } - }); - source.defines.NVR_NORMAL = 1; - source.needsUpdate = true; - mesh.process(() => { - for (const [i, s] of mesh.children.entries()) { - assert.equal(s.material.defines.NVR_NORMAL, 1); - assert.ok(s.material.version > versions[i]); - } - }); - mesh.geometry.dispose(); - source.dispose(); -}); - -test('render exceptions restore original ownership and stencil state; material disposal releases only owned resources', () => { - const mesh = batchStencilDraws(fixture()), - source = mesh.material, - saved = snapshot(source); - let disposed = 0, - geometryDisposed = 0; - mesh.geometry.addEventListener('dispose', () => geometryDisposed++); - assert.throws( - () => - mesh.process(() => { - mesh.material.addEventListener('dispose', () => disposed++); - for (const s of mesh.children) - s.material.addEventListener('dispose', () => disposed++); - throw new Error('draw failure'); - }), - /draw failure/, - ); - assert.equal(mesh.material, source); - assert.equal(mesh.children.length, 0); - assert.deepEqual(snapshot(source), saved); - source.dispose(); - assert.equal(disposed, 4); - assert.equal(geometryDisposed, 0); - mesh.process(() => assert.fail('disposed owner must not allocate or draw')); - source.dispose(); - assert.equal(disposed, 4); - mesh.geometry.dispose(); -}); - -test('hidden meshes allocate nothing; meshes with children keep native behavior', () => { - const mesh = batchStencilDraws(fixture()); - mesh.visible = false; - mesh.process(() => assert.fail('hidden draw')); - assert.equal(mesh.children.length, 0); - mesh.visible = true; - const child = new Group(); - mesh.add(child); - let calls = 0; - mesh.process(() => { - calls++; - assert.deepEqual(mesh.children, [child]); - }); - assert.equal(calls, 3); - mesh.material.dispose(); - mesh.geometry.dispose(); -}); diff --git a/src/layers/installations/render/style.js b/src/layers/installations/render/style.js new file mode 100644 index 000000000..e70d9cb83 --- /dev/null +++ b/src/layers/installations/render/style.js @@ -0,0 +1,36 @@ +// The Mapped Installations look on every engine: sizes in CSS pixels, +// colours as rgba in 0..1, and the draw orders of the layer's sets. The +// class colours are policy.js's COLOR_BY_CLASS (model.js colorFor). + +import { COLOR_BY_CLASS } from '../policy.js'; + +/** A site's disc; the selected site's, drawn white over the others. */ +export const MARKER_SIZE_PX = 9; +export const SELECTED_MARKER_SIZE_PX = 13; +export const SELECTED_MARKER_COLOR = Object.freeze([1, 1, 1, 1]); +/** A named area's disc. */ +export const NAMED_MARKER_SIZE_PX = 7; +export const MARKER_OUTLINE_PX = 1; +export const MARKER_OUTLINE_COLOR = Object.freeze([0, 0, 0, 0.8]); +export const NAMED_MARKER_OUTLINE_COLOR = Object.freeze([0, 0, 0, 1]); + +/** A site's outlines on the ground, in its class colour. */ +export const OUTLINE_WIDTH_PX = 2; +export const OUTLINE_ALPHA = 0.85; +/** The selected site's footprints on the ground, in its class colour. */ +export const SELECTION_FILL_ALPHA = 0.22; + +// Map data under the moving contacts (src/data/spriteOrder.js); the named +// discs over the sites' discs, the selected disc over both. +export const MARKER_ORDER = -1; +export const NAMED_MARKER_ORDER = -0.75; +export const SELECTED_MARKER_ORDER = -0.5; +// The outlines over the selection fill. +export const SELECTION_FILL_ORDER = 0; +export const OUTLINE_ORDER = 1; + +const DEFAULT_ACCENT = '#9ca6b0'; + +/** A site's card and title accent: its class colour, in CSS. */ +export const installationAccent = (record) => + COLOR_BY_CLASS[record?.class] || DEFAULT_ACCENT; diff --git a/src/layers/installations/selection.test.mjs b/src/layers/installations/selection.test.mjs index 051537903..8f016e298 100644 --- a/src/layers/installations/selection.test.mjs +++ b/src/layers/installations/selection.test.mjs @@ -1,12 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; import * as context from '../../data/contextStore.js'; -import { createSelection } from './render/cesium/selection.js'; -import { createRendering } from './render/cesium/rendering.js'; -import { createNamedMarkers } from './render/cesium/namedMarkers.js'; +import { SELECTION_FILL_SET_ID } from './render/display.js'; +import { createDisplayHarness } from './render/displayHarness.mjs'; +import { GROUND_HARNESSES } from '../../testSupport/groundDisplayEngines.mjs'; -function harness(t) { +function harness(t, makeHarness = GROUND_HARNESSES[0]) { const previousWindow = globalThis.window; const previousDocument = globalThis.document; globalThis.window = new EventTarget(); @@ -29,51 +28,8 @@ function harness(t) { ], ], }; - const state = { - enabled: true, - selectedId: null, - records: [record], - recordById: new Map([[record.id, record]]), - renderedKeys: new Map(), - dataSource: new Cesium.CustomDataSource(), - viewer: { - camera: { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1e6) }, - scene: { - globe: { show: false }, - canvas: Object.assign(new EventTarget(), { - clientWidth: 1280, - clientHeight: 800, - }), - primitives: { add: (p) => p, remove() {} }, - }, - }, - }; - t.mock.method( - Cesium.SceneTransforms, - 'worldToWindowCoordinates', - (_scene, _point, scratch) => { - scratch.x = 640; - scratch.y = 400; - return scratch; - }, - ); let labels = []; - const services = { - context, - anchors: {}, - ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 }, - render: { governorRequestRender() {} }, - }; - const parts = { - model: { - colorFor: () => Cesium.Color.ORANGE, - installationSourceLabel: () => 'OpenStreetMap', - }, - }; - const ctx = { - state, - services, - parts, + const h = createDisplayHarness(makeHarness, { overlayHost: { setEntries(_id, entries) { labels = entries; @@ -81,33 +37,30 @@ function harness(t) { clearSource() {}, setVisible() {}, }, - }; - parts.rendering = createRendering(ctx); - parts.namedMarkers = createNamedMarkers(ctx); - const selection = createSelection(ctx); - selection.installInteraction(state.viewer); + }); + h.setRecords([record]); + h.setPhotoreal(true); + h.start(); + const selection = h.parts.selection; t.after(() => { - selection.destroy(); - state.clickHandler.destroy(); - parts.namedMarkers.destroy(); - parts.rendering.clearRendered(); + h.destroy(); if (previousWindow === undefined) delete globalThis.window; else globalThis.window = previousWindow; if (previousDocument === undefined) delete globalThis.document; else globalThis.document = previousDocument; }); const assertSelected = (selected) => { - assert.equal(state.selectedId, selected ? record.id : null); + assert.equal(h.state.selectedId, selected ? record.id : null); assert.equal(labels[0].selected, selected); assert.equal(labels[0].variant, selected ? 'selected' : 'card'); assert.equal( - state.dataSource.entities.values.filter((e) => e.polygon).length, + h.set(SELECTION_FILL_SET_ID)?.items.length ?? 0, selected ? 1 : 0, ); }; assert.equal(selection.selectRecord(record.id), true); assertSelected(true); - return { state, selection, assertSelected }; + return { state: h.state, selection, assertSelected }; } for (const layerId of ['flights', 'military', 'satellites']) { diff --git a/src/layers/installations/selectionFill.test.mjs b/src/layers/installations/selectionFill.test.mjs index c37e65b6a..7058f617f 100644 --- a/src/layers/installations/selectionFill.test.mjs +++ b/src/layers/installations/selectionFill.test.mjs @@ -1,80 +1,134 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import * as C from 'cesium'; -import { createRendering } from './render/cesium/rendering.js'; -import { createLifecycle } from './render/cesium/lifecycle.js'; +import * as Cesium from 'cesium'; +import * as context from '../../data/contextStore.js'; +import { SELECTION_FILL_SET_ID } from './render/display.js'; +import { createDisplayHarness } from './render/displayHarness.mjs'; +import { GROUND_HARNESSES } from '../../testSupport/groundDisplayEngines.mjs'; -function harness() { - const ring = [[-97, 30], [-96.99, 30], [-96.99, 30.01], [-97, 30]]; - const record = { id: 'base', latitude: 30, longitude: -97, name: 'Base', class: 'base', footprints: [[ring, ring.map(([x,y]) => [x+0.001,y+0.001])], [ring]] }; - const state = { enabled: true, selectedId: 'base', records: [record], recordById: new Map([['base', record]]), renderedKeys: new Map(), dataSource: new C.CustomDataSource(), viewer: { scene: { globe: { show: false } }, camera: {} } }; - let listener, removed = 0; - const noop = () => {}; - const services = { ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 }, anchors: {}, render: { governorRequestRender: noop }, context: { removeEntityContextsForLayer: noop, clearSelectedEntityContextForLayer: noop }, picking: { registerPickOwner: noop, unregisterPickOwner: noop }, maps: { subscribeMapStack(cb) { listener = cb; return () => { listener = null; removed++; }; } } }; - const parts = { model: { colorFor: () => C.Color.ORANGE, installationSourceLabel: () => 'OpenStreetMap' }, viewport: { clearUnavailableRetry: noop }, ingestion: { setInstallationStatus: noop } }; - parts.rendering = createRendering({ state, services, parts }); - const lifecycle = createLifecycle({ state, services, parts, source: {} }).methods; - return { state, record, parts, lifecycle, changeSurface: () => listener(), removed: () => removed }; -} - -test('selected multipart footprints classify Google tiles with one constant translucent material and holes', () => { - const h = harness(), render = h.parts.rendering; - render.syncSelectionFill(); - const fills = h.state.dataSource.entities.values.slice(); - assert.equal(fills.length, 2); - for (const fill of fills) { - assert.equal(fill.installationId, 'base'); - assert.equal(fill.polygon.classificationType.getValue(), C.ClassificationType.CESIUM_3D_TILE); - assert.equal(fill.polygon.height, undefined); - assert.equal(fill.polygon.extrudedHeight, undefined); - assert.equal(fill.polygon.material.isConstant, true); - assert.equal(fill.polygon.material.color.getValue().alpha, 0.22); - } - assert.equal(fills[0].polygon.hierarchy.getValue().holes.length, 1); - assert.equal(fills[0].polygon.material, fills[1].polygon.material); - for (let i = 0; i < 100; i++) render.syncSelectionFill(); - assert.deepEqual(h.state.dataSource.entities.values, fills, 'repaints retain ground geometry'); - h.state.selectedId = null; render.syncSelectionFill(); - assert.equal(h.state.dataSource.entities.values.length, 0); -}); - -test('surface changes reclassify selected geometry and disable tears down fills and subscription', () => { - const h = harness(); - h.lifecycle.enable(); h.parts.rendering.syncSelectionFill(); - h.state.viewer.scene.globe.show = true; h.changeSurface(); - assert.ok(h.state.dataSource.entities.values.every(e => e.polygon.classificationType.getValue() === C.ClassificationType.BOTH)); - h.lifecycle.disable(); - assert.equal(h.state.dataSource.entities.values.length, 0); - assert.equal(h.removed(), 1); - h.lifecycle.enable(); h.parts.rendering.syncSelectionFill(); - assert.equal(h.state.dataSource.entities.values.length, 0, 'reenabling cannot revive a stale selection'); - h.lifecycle.disable(); -}); - - -test('geometry rebuild and canonical alias migration preserve shared selection before pruning', async () => { - const context = await import('../../data/contextStore.js'); +function harness(t, makeHarness) { const previousWindow = globalThis.window; globalThis.window = new EventTarget(); - try { - const h = harness(); - const services = { context, render: { governorRequestRender() {} }, ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 }, anchors: {} }; - const rendering = createRendering({ state: h.state, services, parts: h.parts }); + const ring = [ + [-97, 30], + [-96.99, 30], + [-96.99, 30.01], + [-97, 30], + ]; + const record = { + id: 'base', + latitude: 30, + longitude: -97, + name: 'Base', + class: 'base', + footprints: [[ring, ring.map(([x, y]) => [x + 0.001, y + 0.001])], [ring]], + }; + const h = createDisplayHarness(makeHarness); + h.setRecords([record]); + h.state.selectedId = 'base'; + // Photoreal tiles shown: the globe is hidden. + h.setPhotoreal(true); + h.parts.lifecycle.methods.init(); + t.after(() => { + h.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + }); + const fill = () => h.set(SELECTION_FILL_SET_ID); + return { + h, + record, + render: h.parts.rendering, + lifecycle: h.parts.lifecycle.methods, + fill, + }; +} + +for (const makeHarness of GROUND_HARNESSES) { + const { name } = makeHarness(); + + test(`selected multipart footprints drape on Google tiles in one constant translucent colour with holes (${name})`, (t) => { + const { h, render, fill } = harness(t, makeHarness); + h.state.enabled = true; + render.syncSelectionFill(); + const { items, options, commits } = fill(); + assert.equal(items.length, 2); + assert.equal(options.drape, 'tiles'); + for (const item of items) { + assert.equal(item.pickId, 'base'); + assert.equal(item.fill[3], 0.22); + assert.equal(item.height, undefined); + } + assert.equal( + items[0].rings.length, + 2, + 'the first footprint keeps its hole', + ); + assert.deepEqual(items[0].fill, items[1].fill); + if (h.cesium) + assert.equal( + h.primitives(SELECTION_FILL_SET_ID).fills[0].options.classificationType, + Cesium.ClassificationType.CESIUM_3D_TILE, + ); + for (let i = 0; i < 100; i++) render.syncSelectionFill(); + assert.equal(fill().commits, commits, 'repaints retain ground geometry'); + h.state.selectedId = null; + render.syncSelectionFill(); + assert.equal(fill().items.length, 0); + }); + + test(`surface changes re-drape selected geometry and disable tears down fills and subscription (${name})`, (t) => { + const { h, lifecycle, render, fill } = harness(t, makeHarness); + lifecycle.enable(); + h.state.selectedId = 'base'; + render.syncSelectionFill(); + h.setPhotoreal(false); + h.changeMapStack(); + assert.equal(fill().options.drape, 'both'); + assert.equal(fill().items.length, 2); + if (h.cesium) + assert.equal( + h.primitives(SELECTION_FILL_SET_ID).fills[0].options.classificationType, + Cesium.ClassificationType.BOTH, + ); + lifecycle.disable(); + assert.equal(fill().items.length, 0); + assert.equal(h.mapStackRemoved(), 1); + lifecycle.enable(); + render.syncSelectionFill(); + assert.equal( + fill().items.length, + 0, + 'reenabling cannot revive a stale selection', + ); + lifecycle.disable(); + }); + + test(`geometry rebuild and canonical alias migration preserve shared selection before pruning (${name})`, (t) => { + const { h, record, render } = harness(t, makeHarness); + h.state.enabled = true; const cleared = []; - window.addEventListener('gev:entity-selection-cleared', e => { cleared.push(e.detail); h.state.selectedId = null; }); - rendering.renderRecords({ claimSelection: true }); + window.addEventListener('gev:entity-selection-cleared', (e) => { + cleared.push(e.detail); + h.state.selectedId = null; + }); + render.renderRecords({ claimSelection: true }); const oldEntity = context.getSelectedEntityContext().entity; - h.record.latitude += 0.001; - rendering.renderRecords(); + h.setRecords([{ ...record, latitude: record.latitude + 0.001 }]); + render.renderRecords(); assert.equal(h.state.selectedId, 'base'); assert.equal(context.getSelectedEntityContext().id, 'base'); assert.notEqual(context.getSelectedEntityContext().entity, oldEntity); - const replacement = { ...h.record, id: 'canonical', aliasIds: ['base'] }; - h.state.records = [replacement]; h.state.recordById = new Map([['canonical', replacement]]); - rendering.renderRecords(); + const replacement = { ...record, id: 'canonical', aliasIds: ['base'] }; + h.setRecords([replacement]); + render.renderRecords(); assert.equal(h.state.selectedId, 'canonical'); assert.equal(context.getSelectedEntityContext().id, 'canonical'); - assert.deepEqual(cleared, [], 'no transient eviction can tear down the selected card or fill'); - rendering.clearRendered(); - } finally { if (previousWindow === undefined) delete globalThis.window; else globalThis.window = previousWindow; } -}); + assert.deepEqual( + cleared, + [], + 'no transient eviction can tear down the selected card or fill', + ); + render.clearRendered(); + }); +} diff --git a/src/layers/installations/state.js b/src/layers/installations/state.js index 82465cb68..f0c64bd45 100644 --- a/src/layers/installations/state.js +++ b/src/layers/installations/state.js @@ -10,7 +10,6 @@ export function createState({ services }) { Object.assign(state, { viewer: null, engine: null, - dataSource: null, enabled: false, records: [], recordById: new Map(), @@ -30,7 +29,6 @@ export function createState({ services }) { retryAt: 0, failureReason: null, moveEndRemove: null, - clickHandler: null, timer: null, googleSearchRequested: false, /** Contacts subject position; when set, it replaces the camera viewport. */ @@ -39,8 +37,6 @@ export function createState({ services }) { contextPosition: null, /** Coverage of the last successful load: viewport, or radius around a subject. */ coverage: { kind: 'viewport' }, - /** Per-record geometry keys of the rendered entities, for in-place updates. */ - renderedKeys: new Map(), }); return state; } diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index ff0c74ca8..250a67888 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -1,4 +1,5 @@ -import { createNamedMarkers } from '../layers/installations/render/cesium/namedMarkers.js'; +import { createNamedMarkers } from '../layers/installations/render/namedMarkers.js'; +import { rgbaFromCss } from '../layers/shared/color.js'; import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs'; import { readStylesheet } from '../testSupport/readStylesheet.mjs'; import test from 'node:test'; @@ -2423,27 +2424,24 @@ test('diagnostics facade preserves the complete binding shape', () => { }); -test('installation titles share persistent card arbitration and retain a bounded Contacts cohort', (t) => { - const env = installMockEnvironment({ width: 1600, height: 1000 }); +test('installation titles share persistent card arbitration and retain a bounded Contacts cohort', async (t) => { + const { createFakeEngine } = await import('../globe/testing/fakeEngine.js'); + const env = installMockEnvironment({ width: 1600, height: 1000, dpr: 1 }); + const engine = createFakeEngine({ width: 1600, height: 1000, container: env.viewer.container }); t.after(() => env.cleanup()); - const scene = env.viewer.scene; - scene.canvas = env.viewer.canvas; - scene.preRender = new Cesium.Event(); - scene.primitives = { add: (value) => value, remove() {} }; - env.viewer.camera.changed = new Cesium.Event(); - t.mock.method(Cesium.Cartesian3, 'fromDegrees', (lon, lat) => new Cesium.Cartesian3(lon, lat, 0)); - t.mock.method(Cesium.EllipsoidalOccluder.prototype, 'isPointVisible', () => true); - t.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', (_scene, _point, scratch) => { - scratch.x = scratch.y = 100; return scratch; - }); - const state = { enabled: true, viewer: env.viewer, contextAnchor: {} }; - const markers = createNamedMarkers({ state, + // Looking straight down on the sites, about two degrees across. + let longitude = 0; + let height = 200_000; + const look = () => engine.fake.setPose({ longitude: Cesium.Math.toRadians(longitude), latitude: 0, height, heading: 0, pitch: -Math.PI / 2, roll: 0 }); + look(); + const state = { enabled: true, contextAnchor: {} }; + const markers = createNamedMarkers(engine, { state, services: { render: { governorRequestRender() {} } }, - parts: { model: { colorFor: () => Cesium.Color.ORANGE }, rendering: { installationSurfaceHeightM: () => 0 } }, + parts: { model: { colorFor: () => rgbaFromCss('#ff8000'), installationSurfaceHeightM: () => 0 } }, overlayHost: { setEntries: setOverlayEntries, clearSource: clearOverlaySource, setVisible: setOverlaySourceVisible, getDiagnostics: getWorldOverlayDiagnostics }, }); - initWorldOverlay(env.engine); + initWorldOverlay(engine); markers.enable(); const records = Array.from({ length: 40 }, (_, i) => ({ id: `site:${i}`, name: `Base ${i}`, namedArea: true, pointOnly: i % 2 === 0, areaM2: 1000 - i, @@ -2454,27 +2452,27 @@ test('installation titles share persistent card arbitration and retain a bounded records[1].longitude = records[0].longitude + 0.03; records[1].latitude = records[0].latitude; markers.sync(records); - env.postRender.raise(); + engine.fake.frame(); const source = 'military-installations'; - env.advanceTime(250); env.postRender.raise(); + env.advanceTime(250); engine.fake.frame(); assert.equal(markers.stats().labelsOnScreen, 24); assert.ok(getOverlayPaintRect(source, 'site:0')); assert.equal(getOverlayPaintRect(source, 'small'), null); for (let i = 0; i < 30; i++) { - env.viewer.camera.viewMatrix[12] -= 0.003; - if (i > 15) env.viewer.camera.viewMatrix[0] = env.viewer.camera.viewMatrix[5] = 0.03; - scene.preRender.raiseEvent(); env.advanceTime(16); env.postRender.raise(); + longitude -= 0.003; + if (i > 15) height = 4_000_000; + look(); env.advanceTime(16); engine.fake.frame(); assert.ok(markers.stats().labelsOnScreen > 0 && markers.stats().labelsOnScreen <= 24); // Incumbents persist through collisions during a pan, as for Data Centers. if (i < 10) assert.ok(getOverlayPaintRect(source, 'site:0')); } - state.enabled = false; markers.hide(); env.postRender.raise(); + state.enabled = false; markers.hide(); engine.fake.frame(); assert.equal(getWorldOverlayDiagnostics().entriesBySource[source], 0); assert.equal(markers.stats().labelsOnScreen, 0); - state.enabled = true; markers.enable(); env.postRender.raise(); - env.advanceTime(250); env.postRender.raise(); + state.enabled = true; markers.enable(); engine.fake.frame(); + env.advanceTime(250); engine.fake.frame(); assert.ok(markers.stats().labelsOnScreen > 0); - markers.destroy(); env.postRender.raise(); + markers.destroy(); engine.fake.frame(); assert.equal(getWorldOverlayDiagnostics().entriesBySource[source], 0); }); diff --git a/src/sources/militaryTileGeometry.test.mjs b/src/sources/militaryTileGeometry.test.mjs index 61ff32d8f..014350427 100644 --- a/src/sources/militaryTileGeometry.test.mjs +++ b/src/sources/militaryTileGeometry.test.mjs @@ -186,9 +186,10 @@ test('distinct named bases separated by one seam quantization step retain separa }); test('named A+B to B to A+B preserves canonical identity, parent title and selection', async () => { - const C = await import('cesium'); - const { createRendering } = - await import('../layers/installations/render/cesium/rendering.js'); + const { createDisplayHarness } = + await import('../layers/installations/render/displayHarness.mjs'); + const { createFakeGroundHarness } = + await import('../testSupport/groundDisplayEngines.mjs'); const aliases = new Map(); const named = (n, footprint, areaM2) => record(String(n * 10 + 2), footprint, { @@ -208,36 +209,23 @@ test('named A+B to B to A+B preserves canonical identity, parent title and selec const a = named(1, ring(0, 0, 1, 1), 100), b = named(2, ring(1, 0, 2, 1), 10); let selected = { id: 'osm:military:w1' }; - const state = { - records: [], - recordById: new Map(), - selectedId: selected.id, - renderedKeys: new Map(), - dataSource: new C.CustomDataSource(), - }; - const rendering = createRendering({ - state, - source: {}, - parts: { - model: { - colorFor: () => C.Color.ORANGE, - installationSourceLabel: () => 'OpenStreetMap', + const handles = new Map(); + const h = createDisplayHarness(createFakeGroundHarness, { + context: { + getSelectedEntityContext: () => selected, + removeEntityContextsForLayer() {}, + registerEntityContext: (entity, context) => + handles.set(context.id, entity), + selectEntityContext: (entity) => { + selected = entity; }, - }, - services: { - ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 }, - context: { - getSelectedEntityContext: () => selected, - removeEntityContextsForLayer() {}, - registerEntityContext() {}, - selectEntityContext: (entity) => { - selected = entity; - }, - }, - render: { governorRequestRender() {} }, - anchors: {}, + clearSelectedEntityContextForLayer() {}, }, }); + h.parts.lifecycle.methods.init(); + const { state } = h; + state.selectedId = selected.id; + const rendering = h.parts.rendering; for (const members of [[a, b], [b], [a, b]]) { state.records = mergeMilitaryFragments(members, aliases); state.recordById = new Map(state.records.map((r) => [r.id, r])); @@ -246,7 +234,10 @@ test('named A+B to B to A+B preserves canonical identity, parent title and selec assert.deepEqual(state.records[0].memberNames, ['Site 2']); rendering.renderRecords(); assert.equal(state.selectedId, 'osm:military:w1'); - assert.ok(state.dataSource.entities.getById(state.selectedId)); + assert.ok( + handles.has(state.selectedId), + 'the selected site has its handle', + ); } const [detail] = mergeMilitaryFragments([{ ...b, tileZoom: 12 }], aliases); assert.equal(detail.id, 'osm:military:w2'); @@ -255,12 +246,14 @@ test('named A+B to B to A+B preserves canonical identity, parent title and selec 'Site 2', 'coarse parent metadata cannot override a detail split', ); + h.destroy(); }); test('cold names reconcile selection through enrichment, identical groups at another zoom and wide-point handoff', async () => { - const C = await import('cesium'); - const { createRendering } = - await import('../layers/installations/render/cesium/rendering.js'); + const { createDisplayHarness } = + await import('../layers/installations/render/displayHarness.mjs'); + const { createFakeGroundHarness } = + await import('../testSupport/groundDisplayEngines.mjs'); const { retainNamedHandoff } = await import('../layers/installations/ingestion.js'); const aliases = new Map(); @@ -287,47 +280,37 @@ test('cold names reconcile selection through enrichment, identical groups at ano const [warm] = mergeMilitaryFragments(named); let selected = null; const contexts = new Map(); - const state = { - records: [], - recordById: new Map(), - selectedId: null, - renderedKeys: new Map(), - dataSource: new C.CustomDataSource(), - }; - const rendering = createRendering({ - state, - source: {}, - parts: { - model: { - colorFor: () => C.Color.ORANGE, - installationSourceLabel: () => 'OpenStreetMap', + const handles = new Map(); + const h = createDisplayHarness(createFakeGroundHarness, { + context: { + getSelectedEntityContext: () => selected, + removeEntityContextsForLayer: (_id, { retainIds } = {}) => { + for (const id of contexts.keys()) + if (!retainIds?.has(id)) contexts.delete(id); }, - }, - services: { - ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 }, - context: { - getSelectedEntityContext: () => selected, - removeEntityContextsForLayer: (_id, { retainIds } = {}) => { - for (const id of contexts.keys()) - if (!retainIds?.has(id)) contexts.delete(id); - }, - registerEntityContext: (_entity, context) => - contexts.set(context.id, context), - selectEntityContext: (entity) => { - selected = contexts.get(entity.id); - }, + registerEntityContext: (entity, context) => { + contexts.set(context.id, context); + handles.set(context.id, entity); }, - render: { governorRequestRender() {} }, - anchors: {}, + selectEntityContext: (entity) => { + selected = contexts.get(entity.id); + }, + clearSelectedEntityContextForLayer() {}, }, }); + h.parts.lifecycle.methods.init(); + const { state } = h; + const rendering = h.parts.rendering; const publish = (records, claimSelection = false) => { state.records = records; state.recordById = new Map(records.map((r) => [r.id, r])); rendering.renderRecords({ claimSelection }); assert.equal(state.selectedId, records[0].id); assert.equal(selected.id, records[0].id); - assert.ok(state.dataSource.entities.getById(state.selectedId)); + assert.ok( + handles.has(state.selectedId), + 'the selected site has its handle', + ); assert.ok(contexts.has(selected.id)); }; const cold = mergeMilitaryFragments(unnamed, aliases); @@ -380,4 +363,5 @@ test('cold names reconcile selection through enrichment, identical groups at ano 'identity migration cannot reclaim another layer selection', ); assert.equal(selected.id, 'aircraft:other'); + h.destroy(); });