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