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