diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8f7719548..d13106035 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -452,17 +452,17 @@ "src/data/rocketLaunches.js", "src/layers/satellites/source.test.mjs", "src/layers/launches/source.test.mjs", - "src/layers/installations/lifecycle.js", + "src/layers/installations/render/cesium/lifecycle.js", "src/layers/installations/viewport.js", "src/layers/installations/controls.js", "src/layers/installations/source.js", "src/layers/installations/state.js", "src/layers/installations/policy.js", - "src/layers/installations/rendering.js", + "src/layers/installations/render/cesium/rendering.js", "src/layers/installations/ingestion.js", "src/layers/installations/index.js", "src/layers/installations/model.js", - "src/layers/installations/selection.js", + "src/layers/installations/render/cesium/selection.js", "src/layers/awareness/focus.js", "src/layers/awareness/lifecycle.js", "src/layers/awareness/dependencies.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6712a4d4c..f6e560971 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1249,12 +1249,12 @@ "src/layers/installations/controls.js", "src/layers/installations/index.js", "src/layers/installations/ingestion.js", - "src/layers/installations/lifecycle.js", + "src/layers/installations/render/cesium/lifecycle.js", "src/layers/installations/model.js", "src/layers/installations/policy.js", "src/layers/installations/render/cesium.js", - "src/layers/installations/rendering.js", - "src/layers/installations/selection.js", + "src/layers/installations/render/cesium/rendering.js", + "src/layers/installations/render/cesium/selection.js", "src/layers/installations/source.js", "src/layers/installations/state.js", "src/layers/installations/viewport.js", @@ -1272,7 +1272,7 @@ "src/data/local_data/osm_military_names/names.json", "src/data/bundledJson.js", "src/data/retryableLoad.js", - "src/layers/installations/namedMarkers.js", + "src/layers/installations/render/cesium/namedMarkers.js", "src/data/infrastructureOverlayEntry.js", "src/geo/index.js", "src/geo/math.js", @@ -2128,12 +2128,12 @@ "src/layers/installations/controls.js", "src/layers/installations/index.js", "src/layers/installations/ingestion.js", - "src/layers/installations/lifecycle.js", + "src/layers/installations/render/cesium/lifecycle.js", "src/layers/installations/model.js", "src/layers/installations/policy.js", "src/layers/installations/render/cesium.js", - "src/layers/installations/rendering.js", - "src/layers/installations/selection.js", + "src/layers/installations/render/cesium/rendering.js", + "src/layers/installations/render/cesium/selection.js", "src/layers/installations/source.js", "src/layers/installations/state.js", "src/layers/installations/viewport.js", @@ -2625,7 +2625,7 @@ "src/data/militaryNames.js", "src/sources/requestDeadline.js", "src/data/local_data/osm_military_names/names.json", - "src/layers/installations/namedMarkers.js", + "src/layers/installations/render/cesium/namedMarkers.js", "src/data/infrastructureOverlayEntry.js", "src/sources/infrastructureData.js", "src/tools/functions.js", @@ -2700,6 +2700,8 @@ "src/layers/earthquakes/render/navara.js", "src/layers/earthquakes/render/navaraGeometry.js", "src/layers/earthquakes/render/navaraGrid.js", + "src/layers/installations/render/navara.js", + "src/layers/installations/render/navaraGeometry.js", "src/layers/firms/rectangle.js", "src/layers/firms/render/cesium.js", "src/layers/firms/render/navara.js", @@ -2967,12 +2969,12 @@ "src/layers/installations/controls.js", "src/layers/installations/index.js", "src/layers/installations/ingestion.js", - "src/layers/installations/lifecycle.js", + "src/layers/installations/render/cesium/lifecycle.js", "src/layers/installations/model.js", "src/layers/installations/policy.js", "src/layers/installations/render/cesium.js", - "src/layers/installations/rendering.js", - "src/layers/installations/selection.js", + "src/layers/installations/render/cesium/rendering.js", + "src/layers/installations/render/cesium/selection.js", "src/layers/installations/source.js", "src/layers/installations/state.js", "src/layers/installations/viewport.js", @@ -3265,7 +3267,7 @@ "src/data/militaryNames.js", "src/sources/requestDeadline.js", "src/data/local_data/osm_military_names/names.json", - "src/layers/installations/namedMarkers.js", + "src/layers/installations/render/cesium/namedMarkers.js", "src/data/infrastructureOverlayEntry.js", "src/sources/infrastructureData.js", "src/geo/index.js", diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 1a868878c..1975d85b1 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -10,7 +10,7 @@ import { rendererFor, } from './layers/renderers.js'; -test('the Navara mode registers the aircraft layers’ and vessels’ renderers, once', async () => { +test('the Navara mode registers migrated renderers once', async () => { assert.equal(layerAvailableOn('military', 'navara'), false); await Promise.all([ registerNavaraLayerRenderers(), @@ -26,6 +26,7 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers, 'ais-live-vessels', 'earthquakes', 'local-firms', + 'military-installations', ]) assert.equal(layerAvailableOn(id, 'navara'), true, id); assert.deepEqual( @@ -40,6 +41,7 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers, 'satellites', 'rocket-launches', 'ais-live-vessels', + 'military-installations', 'local-firms', ], ); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 72c743fbf..3eff245c0 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -62,6 +62,15 @@ const NAVARA_CREDIT_KEYS = new Set([ */ export async function registerNavaraLayerRenderers() { const loaders = { + 'military-installations': async () => { + const { createInstallationsNavaraRenderer } = + await import('../layers/installations/render/navara.js'); + return (engine, options) => + createInstallationsNavaraRenderer(engine, { + ...options, + ...navaraViews.get(engine), + }); + }, 'local-firms': async () => { const { createFirmsNavaraRenderer } = await import('../layers/firms/render/navara.js'); @@ -151,6 +160,8 @@ export async function createNavaraScene({ MeshDesc: globe.MeshDesc, EffectDesc: globe.EffectDesc, NavaraEffect: globe.NavaraEffect, + DrapedMesh: globe.DrapedMesh, + setupMaterialForDrape: globe.setupMaterialForDrape, }); defer(() => navaraViews.delete(engine)); // The layers drawn on Navara (their render/navara.js), before the catalog diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 9ddb70258..15a64b903 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -34,8 +34,13 @@ export const OPENMAPTILES_CREDIT = { key: 'openfreemap', html: 'Vector tiles: OpenFreeMap © OpenMapTiles', }; +export const GOOGLE_PLACES_CREDIT = { + key: 'google-maps-places', + html: 'Google Maps Places: Google Maps', +}; const osmDisplays = new WeakMap(); +const googlePlacesDisplays = new WeakMap(); const viewerCredits = new WeakMap(); /** @@ -114,6 +119,36 @@ export function hideOsmCredit(target, owner) { return true; } +/** Keep Places attribution visible only while a layer displays Places results. */ +export function showGooglePlacesCredit(target, owner) { + const credits = creditsOf(target); + if (!credits || !owner) return false; + let state = googlePlacesDisplays.get(target); + if (!state) { + state = { owners: new Set(), remove: null }; + googlePlacesDisplays.set(target, state); + } + if (state.owners.has(owner)) return false; + state.owners.add(owner); + state.remove ||= credits.addStatic(GOOGLE_PLACES_CREDIT.html, { + onScreen: true, + }); + credits.request(); + return true; +} + +/** Release this layer's Places attribution when it stops displaying results. */ +export function hideGooglePlacesCredit(target, owner) { + const state = googlePlacesDisplays.get(target); + if (!state?.owners.delete(owner)) return false; + if (!state.owners.size) { + state.remove?.(); + state.remove = null; + } + creditsOf(target)?.request(); + return true; +} + export const DATA_CREDITS = [ // ── Live sources ──────────────────────────────────────────────── { diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index 8528e5285..76508e069 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -160,16 +160,49 @@ test('data credits go through the globe engine credits', async () => { ); }); +test('Google Places attribution is visible only while a layer displays Places data', async () => { + const { showGooglePlacesCredit, hideGooglePlacesCredit } = + await import('./dataCredits.js'); + const { createFakeEngine } = await import('../globe/testing/fakeEngine.js'); + const engine = createFakeEngine(); + assert.equal(showGooglePlacesCredit(engine, 'installations'), true); + assert.equal(showGooglePlacesCredit(engine, 'installations'), false); + assert.equal(showGooglePlacesCredit(engine, 'other-layer'), true); + const visible = engine.fake.credits.filter(({ onScreen }) => onScreen); + assert.equal(visible.length, 1); + assert.match(visible[0].html, /Google Maps Places/); + assert.match( + visible[0].html, + /developers\.google\.com\/maps\/documentation\/places/, + ); + assert.ok(engine.fake.callsOf('render.request').length > 0); + assert.equal(hideGooglePlacesCredit(engine, 'installations'), true); + assert.equal( + engine.fake.credits.filter(({ onScreen }) => onScreen).length, + 1, + ); + assert.equal(hideGooglePlacesCredit(engine, 'other-layer'), true); + assert.equal(hideGooglePlacesCredit(engine, 'installations'), false); + assert.equal( + engine.fake.credits.filter(({ onScreen }) => onScreen).length, + 0, + ); +}); + test('without a viewer or engine the OSM and dynamic credits skip cleanly', async () => { const { registerDataCredits, registerDynamicCredit, showOsmCredit, hideOsmCredit, + showGooglePlacesCredit, + hideGooglePlacesCredit, } = await import('./dataCredits.js'); // The Navara mode has no viewer; a layer-side call with it must not throw. assert.doesNotThrow(() => registerDataCredits(null, [{ html: 'A' }])); assert.equal(showOsmCredit(null, 'owner'), false); assert.equal(hideOsmCredit(null, 'owner'), false); + assert.equal(showGooglePlacesCredit(null, 'owner'), false); + assert.equal(hideGooglePlacesCredit(null, 'owner'), false); assert.equal(registerDynamicCredit(null, { key: 'k', html: 'K' }), false); }); diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index a0c34c201..d59fa3a60 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -121,7 +121,7 @@ test('every scene click handler consults ownership before it picks', () => { ['src/layers/bikeshare/selection.js', 'viewer.scene.pick(click.position)'], ['src/layers/firms/selection.js', 'scene.pick(click.position)'], [ - 'src/layers/installations/selection.js', + 'src/layers/installations/render/cesium/selection.js', 'viewer.scene.pick(click.position)', ], ['src/layers/launches/render/cesium.js', 'drillPick(movement.position'], diff --git a/src/data/trackedReadout.test.mjs b/src/data/trackedReadout.test.mjs index bb4f66b79..f52332f94 100644 --- a/src/data/trackedReadout.test.mjs +++ b/src/data/trackedReadout.test.mjs @@ -276,13 +276,18 @@ test('tracking layers write gevLabelModel and expose only their cached display p 'flights.js', 'militaryFlights.js', 'satellites.js', - 'militaryInstallations.js', ].map(async (name) => [name, readLayerSource(new URL(`./${name}`, import.meta.url))])); const sources = Object.fromEntries(files); for (const [name, source] of files) { assert.ok(source.includes('.gevLabelModel ='), `${name} writes the explicit model directly`); 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), + 'utf8', + ); + assert.ok(installations.includes('entity.gevLabelModel =')); + assert.ok(installations.includes('entity.gevDisplayPosition = () => displayPosition')); 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/globe/navara/index.js b/src/globe/navara/index.js index 579ff6c98..8a6a886c2 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -6,6 +6,8 @@ import ThreeView, { MeshDesc, EffectDesc, Effect as NavaraEffect, + DrapedMesh, + setupMaterialForDrape, } from '@navaramap/three'; import * as geo from '../../geo/index.js'; import { DefaultPlugin } from '@navaramap/three-default-plugin'; @@ -193,6 +195,8 @@ export async function createNavaraGlobe({ MeshDesc, EffectDesc, NavaraEffect, + DrapedMesh, + setupMaterialForDrape, destroy() { if (destroyed) return; destroyed = true; diff --git a/src/layers/installations/ingestion.js b/src/layers/installations/ingestion.js index 57cda2710..56522f79b 100644 --- a/src/layers/installations/ingestion.js +++ b/src/layers/installations/ingestion.js @@ -72,7 +72,8 @@ export function createIngestion({ } async function loadInstallations() { - if (!layerState.enabled || !layerState.viewer) return; + if (!layerState.enabled || (!layerState.viewer && !layerState.engine)) + return; const { box, coverage } = parts.viewport.loadArea(layerState.viewer); // Guidance, not a fault: the layer chose not to query because the view is // unbounded (a global view, or Cockpit looking at the horizon). Keep it out diff --git a/src/layers/installations/lifecycle.test.mjs b/src/layers/installations/lifecycle.test.mjs index 09a60cc70..772e6659a 100644 --- a/src/layers/installations/lifecycle.test.mjs +++ b/src/layers/installations/lifecycle.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as credits from '../../data/dataCredits.js'; -import { createLifecycle } from './lifecycle.js'; +import { createLifecycle } from './render/cesium/lifecycle.js'; import { LAYER_ID } from './policy.js'; for (const source of [ diff --git a/src/layers/installations/namedMarkers.test.mjs b/src/layers/installations/namedMarkers.test.mjs index 6e5283691..8b56335aa 100644 --- a/src/layers/installations/namedMarkers.test.mjs +++ b/src/layers/installations/namedMarkers.test.mjs @@ -1,8 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as C from 'cesium'; -import { createNamedMarkers } from './namedMarkers.js'; -import { createLifecycle } from './lifecycle.js'; +import { createNamedMarkers } from './render/cesium/namedMarkers.js'; +import { createLifecycle } from './render/cesium/lifecycle.js'; import { createLocalInfrastructureOverlayEntry } from '../../data/localGeojsonCore.js'; function setup(t) { @@ -183,3 +183,17 @@ test('Contacts can select a point outside the normal title cap or an unnamed pol } h.lifecycle.destroy(h.state.viewer); }); + +test('selected markers without an explicit namedArea flag stay hidden safely', (t) => { + const h = setup(t); + const record = { + id: 'google:places-qa-01', + name: 'Candidate', + latitude: 0, + longitude: 0, + }; + 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 dd3a4b0fe..0c1d9c308 100644 --- a/src/layers/installations/navigation.test.mjs +++ b/src/layers/installations/navigation.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { createViewport } from './viewport.js'; -import { createLifecycle } from './lifecycle.js'; +import { createLifecycle } from './render/cesium/lifecycle.js'; test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and recover from wide views', (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); @@ -158,6 +158,49 @@ test('an aborting real ingestion cannot clear the next scheduled view key', asyn assert.equal(state.cameraLoadKey, nextKey); }); +test('Navara installation ingestion loads from its engine camera without a viewer', async () => { + const { createIngestion } = await import('./ingestion.js'); + const rectangle = Cesium.Rectangle.fromDegrees(-123, 37, -122, 38); + const state = { + enabled: true, + viewer: null, + engine: { camera: { viewRectangle: () => rectangle } }, + records: [], + recordById: new Map(), + coverage: { kind: 'viewport' }, + }; + const requests = []; + const parts = { + model: { installationWithinViewport: () => true }, + rendering: { renderRecords() {}, warmInstallationFloors() {} }, + namedMarkers: { sync() {} }, + }; + const context = { + state, + parts, + services: { + render: { governorRequestRender() {} }, + ground: { resolveGroundFloorCellsBounded: async () => {} }, + }, + source: { + async getMappedSites(box) { + requests.push(box); + return { records: [], status: 'ready', wide: false, saturated: false }; + }, + }, + }; + parts.viewport = createViewport(context); + const ingestion = createIngestion(context); + + await ingestion.loadInstallations(); + + assert.equal(requests.length, 1); + assert.ok(Math.abs(requests[0].west + 123) < 1e-10); + assert.ok(Math.abs(requests[0].south - 37) < 1e-10); + assert.equal(state.loading, false); + assert.ok(state.lastUpdate); +}); + test('wide views refresh screen decluttering when the globe rectangle stays unchanged', (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); const camera = { diff --git a/src/layers/installations/render/cesium.js b/src/layers/installations/render/cesium.js index 4ce68ca27..a20cddf9b 100644 --- a/src/layers/installations/render/cesium.js +++ b/src/layers/installations/render/cesium.js @@ -1,7 +1,7 @@ -import { createLifecycle } from '../lifecycle.js'; -import { createNamedMarkers } from '../namedMarkers.js'; -import { createRendering } from '../rendering.js'; -import { createSelection } from '../selection.js'; +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) { diff --git a/src/layers/installations/lifecycle.js b/src/layers/installations/render/cesium/lifecycle.js similarity index 91% rename from src/layers/installations/lifecycle.js rename to src/layers/installations/render/cesium/lifecycle.js index 4d575d91e..fb7e85e3f 100644 --- a/src/layers/installations/lifecycle.js +++ b/src/layers/installations/render/cesium/lifecycle.js @@ -1,10 +1,10 @@ import * as Cesium from 'cesium'; -import { LAYER_ID } from './policy.js'; -import { engineFromViewer } from '../../globe/cesium/engineFromViewer.js'; +import { LAYER_ID } from '../../policy.js'; +import { engineFromViewer } from '../../../../globe/cesium/engineFromViewer.js'; import { claimCameraSensitivity, releaseCameraSensitivity, -} from '../../data/cameraSensitivity.js'; +} from '../../../../data/cameraSensitivity.js'; export function createLifecycle({ state: layerState, @@ -23,7 +23,7 @@ export function createLifecycle({ layerState.dataSource = new Cesium.CustomDataSource( 'military-installations', ); - viewer.dataSources.add(layerState.dataSource); + const added = viewer.dataSources.add(layerState.dataSource); const camera = engine?.camera ?? viewer.camera; const cameraChanged = () => { if (!layerState.contextAnchor) parts.viewport.scheduleLoad(); @@ -32,6 +32,7 @@ export function createLifecycle({ layerState.changedRemove = camera.changed?.addEventListener(cameraChanged); parts.selection.installInteraction(viewer); + return added; }, enable() { @@ -64,6 +65,7 @@ export function createLifecycle({ disable() { layerState.enabled = false; services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID); + services.credits?.hideGooglePlacesCredit?.(layerState.viewer, LAYER_ID); parts.namedMarkers?.hide(); releaseCameraSensitivity(layerState.viewer?.camera, LAYER_ID); layerState.contextAnchor = null; diff --git a/src/layers/installations/namedMarkers.js b/src/layers/installations/render/cesium/namedMarkers.js similarity index 95% rename from src/layers/installations/namedMarkers.js rename to src/layers/installations/render/cesium/namedMarkers.js index e516fb627..ab4f31aa1 100644 --- a/src/layers/installations/namedMarkers.js +++ b/src/layers/installations/render/cesium/namedMarkers.js @@ -2,9 +2,9 @@ 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'; +} 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 }) { @@ -34,7 +34,9 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) { for (let index = 0; index < ordered.length; index++) { const marker = ordered[index]; const point = marker.point; - point.show = marker.named && occluder.isPointVisible(point.position); + point.show = Boolean( + marker.named && occluder.isPointVisible(point.position), + ); if (!point.show) continue; const projected = Cesium.SceneTransforms.worldToWindowCoordinates( viewer.scene, diff --git a/src/layers/installations/rendering.js b/src/layers/installations/render/cesium/rendering.js similarity index 95% rename from src/layers/installations/rendering.js rename to src/layers/installations/render/cesium/rendering.js index be7e29d75..d81d0617a 100644 --- a/src/layers/installations/rendering.js +++ b/src/layers/installations/render/cesium/rendering.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js'; +import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from '../../policy.js'; export function createRendering({ state: layerState, @@ -154,6 +154,16 @@ export function createRendering({ 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. @@ -194,6 +204,7 @@ export function createRendering({ ), }); 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)]), @@ -257,7 +268,7 @@ export function createRendering({ Cesium.Cartesian3.fromDegrees(lon, lat), ), width: 2, - material: color.withAlpha(0.85), + material: new Cesium.ColorMaterialProperty(color.withAlpha(0.85)), clampToGround: true, classificationType: Cesium.ClassificationType.BOTH, }, diff --git a/src/layers/installations/render/cesium/rendering.test.mjs b/src/layers/installations/render/cesium/rendering.test.mjs new file mode 100644 index 000000000..49b24b948 --- /dev/null +++ b/src/layers/installations/render/cesium/rendering.test.mjs @@ -0,0 +1,72 @@ +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/selection.js b/src/layers/installations/render/cesium/selection.js similarity index 96% rename from src/layers/installations/selection.js rename to src/layers/installations/render/cesium/selection.js index 26a4980ef..ac3fc9e33 100644 --- a/src/layers/installations/selection.js +++ b/src/layers/installations/render/cesium/selection.js @@ -1,6 +1,6 @@ import * as Cesium from 'cesium'; -import { isPointerFree } from '../../data/inputOwnership.js'; -import { LAYER_ID } from './policy.js'; +import { isPointerFree } from '../../../../data/inputOwnership.js'; +import { LAYER_ID } from '../../policy.js'; export function createSelection({ state: layerState, diff --git a/src/layers/installations/render/navara.js b/src/layers/installations/render/navara.js new file mode 100644 index 000000000..3eb039bfd --- /dev/null +++ b/src/layers/installations/render/navara.js @@ -0,0 +1,659 @@ +import { Effect, EffectAttribute } from 'postprocessing'; +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 { COLOR_BY_CLASS, LAYER_ID, MAX_RENDERED } from '../policy.js'; +import { + createInstallationDrapedMesh, + createInstallationOutlineDrapedMesh, +} from './navaraGeometry.js'; + +const registeredViews = new WeakSet(); +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; +} +`; + +function createDrapeEffectClass(EffectDesc, NavaraEffect) { + return class InstallationDrapeDesc extends EffectDesc { + static key = KEY; + static insertAfter = ['mrt']; + + constructor(view, ctx, config) { + super(view, ctx, config); + this.owner = config[KEY].owner; + } + + createPass() { + this.effect = new Effect('GevInstallationDrape', IDLE_SHADER, { + attributes: EffectAttribute.CONVOLUTION, + }); + return new NavaraEffect(this.view.camera.raw, this.effect); + } + + 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); + } + + onDestroy() { + 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; + this.effect?.dispose(); + super.onDestroy(); + } + }; +} + +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, + }, +) { + if ( + !engine?.movers || + !view?.registerEffect || + !EffectDesc || + !NavaraEffect || + !DrapedMesh || + !setupMaterialForDrape + ) + throw new TypeError('Installations require a Navara view'); + if (!registeredViews.has(view)) { + view.registerEffect(KEY, createDrapeEffectClass(EffectDesc, NavaraEffect)); + registeredViews.add(view); + } + + 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 outlinesById = new Map(); + 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 = new Map( + records + .filter((record) => !record.pointOnly && !record.namedArea) + .map((record) => [record.id, record]), + ); + for (const [id, entry] of outlinesById) { + if (desired.get(id) === entry.record && entry.widthM === widthM) continue; + replaceOwnedMeshes(entry.meshes, []); + outlinesById.delete(id); + } + for (const [id, record] of desired) { + if (outlinesById.has(id)) continue; + 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); + const owned = new Set(meshes); + for (const mesh of owned) owner.meshes.add(mesh); + outlinesById.set(id, { record, widthM, meshes: owned }); + } + syncDrapeEffect(); + } + + function clearOutlines() { + for (const entry of outlinesById.values()) + replaceOwnedMeshes(entry.meshes, []); + outlinesById.clear(); + } + + function registerContext(record) { + let context = contextObjects.get(record.id); + if (!context) { + context = {}; + contextObjects.set(record.id, context); + } + context.gevContextId = record.id; + context.gevLabelModel = { + title: record.name || 'MAPPED INSTALLATION', + details: [ + String(record.class || 'installation') + .replaceAll('_', ' ') + .toUpperCase(), + ...(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(); + }, + 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(); + layerState.moveEndRemove?.(); + layerState.changedRemove?.(); + layerState.moveEndRemove = layerState.changedRemove = null; + selection.destroy(); + rendering.clearRendered(); + namedMarkers.destroy(); + drapeHandle?.destroy(); + 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 new file mode 100644 index 000000000..b8900689c --- /dev/null +++ b/src/layers/installations/render/navara.test.mjs @@ -0,0 +1,147 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Mesh } from 'three'; +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() {}, + }, + 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; + const view = { + canvas: { clientHeight: 800 }, + camera: { raw: { fov: 60 } }, + registerEffect() {}, + addEffect(config) { + effectConfig = config; + return { update() {}, destroy() {} }; + }, + }; + 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() {} }, + }; + 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); +}); diff --git a/src/layers/installations/render/navaraGeometry.js b/src/layers/installations/render/navaraGeometry.js new file mode 100644 index 000000000..2fa710429 --- /dev/null +++ b/src/layers/installations/render/navaraGeometry.js @@ -0,0 +1,200 @@ +import { + DoubleSide, + ExtrudeGeometry, + MeshLambertMaterial, + Path, + Shape, +} from 'three'; +import * as geo from '../../../geo/index.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 = 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 = 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 new file mode 100644 index 000000000..203cad5b0 --- /dev/null +++ b/src/layers/installations/render/navaraGeometry.test.mjs @@ -0,0 +1,136 @@ +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/selection.test.mjs b/src/layers/installations/selection.test.mjs index b66f1d91b..051537903 100644 --- a/src/layers/installations/selection.test.mjs +++ b/src/layers/installations/selection.test.mjs @@ -2,9 +2,9 @@ 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 './selection.js'; -import { createRendering } from './rendering.js'; -import { createNamedMarkers } from './namedMarkers.js'; +import { createSelection } from './render/cesium/selection.js'; +import { createRendering } from './render/cesium/rendering.js'; +import { createNamedMarkers } from './render/cesium/namedMarkers.js'; function harness(t) { const previousWindow = globalThis.window; diff --git a/src/layers/installations/selectionFill.test.mjs b/src/layers/installations/selectionFill.test.mjs index e3a29c376..c37e65b6a 100644 --- a/src/layers/installations/selectionFill.test.mjs +++ b/src/layers/installations/selectionFill.test.mjs @@ -1,8 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as C from 'cesium'; -import { createRendering } from './rendering.js'; -import { createLifecycle } from './lifecycle.js'; +import { createRendering } from './render/cesium/rendering.js'; +import { createLifecycle } from './render/cesium/lifecycle.js'; function harness() { const ring = [[-97, 30], [-96.99, 30], [-96.99, 30.01], [-97, 30]]; diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index 0a3b6e340..ab13d9df3 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -1,4 +1,4 @@ -import { createNamedMarkers } from '../layers/installations/namedMarkers.js'; +import { createNamedMarkers } from '../layers/installations/render/cesium/namedMarkers.js'; import { expandApplicationHtml } from '../../build/application-html.js'; import { readStylesheet } from '../testSupport/readStylesheet.mjs'; import test from 'node:test'; diff --git a/src/sources/militaryTileGeometry.test.mjs b/src/sources/militaryTileGeometry.test.mjs index 5ea38dddd..61ff32d8f 100644 --- a/src/sources/militaryTileGeometry.test.mjs +++ b/src/sources/militaryTileGeometry.test.mjs @@ -188,7 +188,7 @@ 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/rendering.js'); + await import('../layers/installations/render/cesium/rendering.js'); const aliases = new Map(); const named = (n, footprint, areaM2) => record(String(n * 10 + 2), footprint, { @@ -260,7 +260,7 @@ test('named A+B to B to A+B preserves canonical identity, parent title and selec 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/rendering.js'); + await import('../layers/installations/render/cesium/rendering.js'); const { retainNamedHandoff } = await import('../layers/installations/ingestion.js'); const aliases = new Map();