feat(installations): add Navara renderer

This commit is contained in:
German Khokhlov committed 2026-10-28 12:18:03 +04:00
1 parent 965a82144c
commit 1a694baf5a
27 files changed
+1426 -47

No files matched your search

+3 -3
View File
@@ -452,17 +452,17 @@
"src/data/rocketLaunches.js", "src/data/rocketLaunches.js",
"src/layers/satellites/source.test.mjs", "src/layers/satellites/source.test.mjs",
"src/layers/launches/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/viewport.js",
"src/layers/installations/controls.js", "src/layers/installations/controls.js",
"src/layers/installations/source.js", "src/layers/installations/source.js",
"src/layers/installations/state.js", "src/layers/installations/state.js",
"src/layers/installations/policy.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/ingestion.js",
"src/layers/installations/index.js", "src/layers/installations/index.js",
"src/layers/installations/model.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/focus.js",
"src/layers/awareness/lifecycle.js", "src/layers/awareness/lifecycle.js",
"src/layers/awareness/dependencies.js", "src/layers/awareness/dependencies.js",
+14 -12
View File
@@ -1249,12 +1249,12 @@
"src/layers/installations/controls.js", "src/layers/installations/controls.js",
"src/layers/installations/index.js", "src/layers/installations/index.js",
"src/layers/installations/ingestion.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/model.js",
"src/layers/installations/policy.js", "src/layers/installations/policy.js",
"src/layers/installations/render/cesium.js", "src/layers/installations/render/cesium.js",
"src/layers/installations/rendering.js", "src/layers/installations/render/cesium/rendering.js",
"src/layers/installations/selection.js", "src/layers/installations/render/cesium/selection.js",
"src/layers/installations/source.js", "src/layers/installations/source.js",
"src/layers/installations/state.js", "src/layers/installations/state.js",
"src/layers/installations/viewport.js", "src/layers/installations/viewport.js",
@@ -1272,7 +1272,7 @@
"src/data/local_data/osm_military_names/names.json", "src/data/local_data/osm_military_names/names.json",
"src/data/bundledJson.js", "src/data/bundledJson.js",
"src/data/retryableLoad.js", "src/data/retryableLoad.js",
"src/layers/installations/namedMarkers.js", "src/layers/installations/render/cesium/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js", "src/data/infrastructureOverlayEntry.js",
"src/geo/index.js", "src/geo/index.js",
"src/geo/math.js", "src/geo/math.js",
@@ -2128,12 +2128,12 @@
"src/layers/installations/controls.js", "src/layers/installations/controls.js",
"src/layers/installations/index.js", "src/layers/installations/index.js",
"src/layers/installations/ingestion.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/model.js",
"src/layers/installations/policy.js", "src/layers/installations/policy.js",
"src/layers/installations/render/cesium.js", "src/layers/installations/render/cesium.js",
"src/layers/installations/rendering.js", "src/layers/installations/render/cesium/rendering.js",
"src/layers/installations/selection.js", "src/layers/installations/render/cesium/selection.js",
"src/layers/installations/source.js", "src/layers/installations/source.js",
"src/layers/installations/state.js", "src/layers/installations/state.js",
"src/layers/installations/viewport.js", "src/layers/installations/viewport.js",
@@ -2625,7 +2625,7 @@
"src/data/militaryNames.js", "src/data/militaryNames.js",
"src/sources/requestDeadline.js", "src/sources/requestDeadline.js",
"src/data/local_data/osm_military_names/names.json", "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/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js", "src/sources/infrastructureData.js",
"src/tools/functions.js", "src/tools/functions.js",
@@ -2700,6 +2700,8 @@
"src/layers/earthquakes/render/navara.js", "src/layers/earthquakes/render/navara.js",
"src/layers/earthquakes/render/navaraGeometry.js", "src/layers/earthquakes/render/navaraGeometry.js",
"src/layers/earthquakes/render/navaraGrid.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/rectangle.js",
"src/layers/firms/render/cesium.js", "src/layers/firms/render/cesium.js",
"src/layers/firms/render/navara.js", "src/layers/firms/render/navara.js",
@@ -2967,12 +2969,12 @@
"src/layers/installations/controls.js", "src/layers/installations/controls.js",
"src/layers/installations/index.js", "src/layers/installations/index.js",
"src/layers/installations/ingestion.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/model.js",
"src/layers/installations/policy.js", "src/layers/installations/policy.js",
"src/layers/installations/render/cesium.js", "src/layers/installations/render/cesium.js",
"src/layers/installations/rendering.js", "src/layers/installations/render/cesium/rendering.js",
"src/layers/installations/selection.js", "src/layers/installations/render/cesium/selection.js",
"src/layers/installations/source.js", "src/layers/installations/source.js",
"src/layers/installations/state.js", "src/layers/installations/state.js",
"src/layers/installations/viewport.js", "src/layers/installations/viewport.js",
@@ -3265,7 +3267,7 @@
"src/data/militaryNames.js", "src/data/militaryNames.js",
"src/sources/requestDeadline.js", "src/sources/requestDeadline.js",
"src/data/local_data/osm_military_names/names.json", "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/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js", "src/sources/infrastructureData.js",
"src/geo/index.js", "src/geo/index.js",
+3 -1
View File
@@ -10,7 +10,7 @@ import {
rendererFor, rendererFor,
} from './layers/renderers.js'; } 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); assert.equal(layerAvailableOn('military', 'navara'), false);
await Promise.all([ await Promise.all([
registerNavaraLayerRenderers(), registerNavaraLayerRenderers(),
@@ -26,6 +26,7 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers,
'ais-live-vessels', 'ais-live-vessels',
'earthquakes', 'earthquakes',
'local-firms', 'local-firms',
'military-installations',
]) ])
assert.equal(layerAvailableOn(id, 'navara'), true, id); assert.equal(layerAvailableOn(id, 'navara'), true, id);
assert.deepEqual( assert.deepEqual(
@@ -40,6 +41,7 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers,
'satellites', 'satellites',
'rocket-launches', 'rocket-launches',
'ais-live-vessels', 'ais-live-vessels',
'military-installations',
'local-firms', 'local-firms',
], ],
); );
+11
View File
@@ -62,6 +62,15 @@ const NAVARA_CREDIT_KEYS = new Set([
*/ */
export async function registerNavaraLayerRenderers() { export async function registerNavaraLayerRenderers() {
const loaders = { 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 () => { 'local-firms': async () => {
const { createFirmsNavaraRenderer } = const { createFirmsNavaraRenderer } =
await import('../layers/firms/render/navara.js'); await import('../layers/firms/render/navara.js');
@@ -151,6 +160,8 @@ export async function createNavaraScene({
MeshDesc: globe.MeshDesc, MeshDesc: globe.MeshDesc,
EffectDesc: globe.EffectDesc, EffectDesc: globe.EffectDesc,
NavaraEffect: globe.NavaraEffect, NavaraEffect: globe.NavaraEffect,
DrapedMesh: globe.DrapedMesh,
setupMaterialForDrape: globe.setupMaterialForDrape,
}); });
defer(() => navaraViews.delete(engine)); defer(() => navaraViews.delete(engine));
// The layers drawn on Navara (their render/navara.js), before the catalog // The layers drawn on Navara (their render/navara.js), before the catalog
+35
View File
@@ -34,8 +34,13 @@ export const OPENMAPTILES_CREDIT = {
key: 'openfreemap', key: 'openfreemap',
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>', html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
}; };
export const GOOGLE_PLACES_CREDIT = {
key: 'google-maps-places',
html: 'Google Maps Places: <a href="https://developers.google.com/maps/documentation/places/web-service/policies" target="_blank" rel="noopener">Google Maps</a>',
};
const osmDisplays = new WeakMap(); const osmDisplays = new WeakMap();
const googlePlacesDisplays = new WeakMap();
const viewerCredits = new WeakMap(); const viewerCredits = new WeakMap();
/** /**
@@ -114,6 +119,36 @@ export function hideOsmCredit(target, owner) {
return true; 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 = [ export const DATA_CREDITS = [
// ── Live sources ──────────────────────────────────────────────── // ── Live sources ────────────────────────────────────────────────
{ {
+33
View File
@@ -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 () => { test('without a viewer or engine the OSM and dynamic credits skip cleanly', async () => {
const { const {
registerDataCredits, registerDataCredits,
registerDynamicCredit, registerDynamicCredit,
showOsmCredit, showOsmCredit,
hideOsmCredit, hideOsmCredit,
showGooglePlacesCredit,
hideGooglePlacesCredit,
} = await import('./dataCredits.js'); } = await import('./dataCredits.js');
// The Navara mode has no viewer; a layer-side call with it must not throw. // The Navara mode has no viewer; a layer-side call with it must not throw.
assert.doesNotThrow(() => registerDataCredits(null, [{ html: 'A' }])); assert.doesNotThrow(() => registerDataCredits(null, [{ html: 'A' }]));
assert.equal(showOsmCredit(null, 'owner'), false); assert.equal(showOsmCredit(null, 'owner'), false);
assert.equal(hideOsmCredit(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); assert.equal(registerDynamicCredit(null, { key: 'k', html: 'K' }), false);
}); });
+1 -1
View File
@@ -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/bikeshare/selection.js', 'viewer.scene.pick(click.position)'],
['src/layers/firms/selection.js', '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)', 'viewer.scene.pick(click.position)',
], ],
['src/layers/launches/render/cesium.js', 'drillPick(movement.position'], ['src/layers/launches/render/cesium.js', 'drillPick(movement.position'],
+6 -1
View File
@@ -276,13 +276,18 @@ test('tracking layers write gevLabelModel and expose only their cached display p
'flights.js', 'flights.js',
'militaryFlights.js', 'militaryFlights.js',
'satellites.js', 'satellites.js',
'militaryInstallations.js',
].map(async (name) => [name, readLayerSource(new URL(`./${name}`, import.meta.url))])); ].map(async (name) => [name, readLayerSource(new URL(`./${name}`, import.meta.url))]));
const sources = Object.fromEntries(files); const sources = Object.fromEntries(files);
for (const [name, source] of files) { for (const [name, source] of files) {
assert.ok(source.includes('.gevLabelModel ='), `${name} writes the explicit model directly`); assert.ok(source.includes('.gevLabelModel ='), `${name} writes the explicit model directly`);
assert.ok(source.includes('.gevDisplayPosition ='), `${name} exposes a display-position cache`); 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['flights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/);
assert.match(sources['militaryFlights.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')); assert.ok(sources['satellites.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
+4
View File
@@ -6,6 +6,8 @@ import ThreeView, {
MeshDesc, MeshDesc,
EffectDesc, EffectDesc,
Effect as NavaraEffect, Effect as NavaraEffect,
DrapedMesh,
setupMaterialForDrape,
} from '@navaramap/three'; } from '@navaramap/three';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { DefaultPlugin } from '@navaramap/three-default-plugin'; import { DefaultPlugin } from '@navaramap/three-default-plugin';
@@ -193,6 +195,8 @@ export async function createNavaraGlobe({
MeshDesc, MeshDesc,
EffectDesc, EffectDesc,
NavaraEffect, NavaraEffect,
DrapedMesh,
setupMaterialForDrape,
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
destroyed = true; destroyed = true;
+2 -1
View File
@@ -72,7 +72,8 @@ export function createIngestion({
} }
async function loadInstallations() { 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); const { box, coverage } = parts.viewport.loadArea(layerState.viewer);
// Guidance, not a fault: the layer chose not to query because the view is // 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 // unbounded (a global view, or Cockpit looking at the horizon). Keep it out
+1 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as credits from '../../data/dataCredits.js'; 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'; import { LAYER_ID } from './policy.js';
for (const source of [ for (const source of [
+16 -2
View File
@@ -1,8 +1,8 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as C from 'cesium'; import * as C from 'cesium';
import { createNamedMarkers } from './namedMarkers.js'; import { createNamedMarkers } from './render/cesium/namedMarkers.js';
import { createLifecycle } from './lifecycle.js'; import { createLifecycle } from './render/cesium/lifecycle.js';
import { createLocalInfrastructureOverlayEntry } from '../../data/localGeojsonCore.js'; import { createLocalInfrastructureOverlayEntry } from '../../data/localGeojsonCore.js';
function setup(t) { 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); 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);
});
+44 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createViewport } from './viewport.js'; 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) => { test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and recover from wide views', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] }); 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); 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) => { test('wide views refresh screen decluttering when the globe rectangle stays unchanged', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const camera = { const camera = {
+4 -4
View File
@@ -1,7 +1,7 @@
import { createLifecycle } from '../lifecycle.js'; import { createLifecycle } from './cesium/lifecycle.js';
import { createNamedMarkers } from '../namedMarkers.js'; import { createNamedMarkers } from './cesium/namedMarkers.js';
import { createRendering } from '../rendering.js'; import { createRendering } from './cesium/rendering.js';
import { createSelection } from '../selection.js'; import { createSelection } from './cesium/selection.js';
/** Compose the existing Cesium-owned scene, input and entity adapters. */ /** Compose the existing Cesium-owned scene, input and entity adapters. */
export function createInstallationsCesiumRenderer(context) { export function createInstallationsCesiumRenderer(context) {
@@ -1,10 +1,10 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { LAYER_ID } from './policy.js'; import { LAYER_ID } from '../../policy.js';
import { engineFromViewer } from '../../globe/cesium/engineFromViewer.js'; import { engineFromViewer } from '../../../../globe/cesium/engineFromViewer.js';
import { import {
claimCameraSensitivity, claimCameraSensitivity,
releaseCameraSensitivity, releaseCameraSensitivity,
} from '../../data/cameraSensitivity.js'; } from '../../../../data/cameraSensitivity.js';
export function createLifecycle({ export function createLifecycle({
state: layerState, state: layerState,
@@ -23,7 +23,7 @@ export function createLifecycle({
layerState.dataSource = new Cesium.CustomDataSource( layerState.dataSource = new Cesium.CustomDataSource(
'military-installations', 'military-installations',
); );
viewer.dataSources.add(layerState.dataSource); const added = viewer.dataSources.add(layerState.dataSource);
const camera = engine?.camera ?? viewer.camera; const camera = engine?.camera ?? viewer.camera;
const cameraChanged = () => { const cameraChanged = () => {
if (!layerState.contextAnchor) parts.viewport.scheduleLoad(); if (!layerState.contextAnchor) parts.viewport.scheduleLoad();
@@ -32,6 +32,7 @@ export function createLifecycle({
layerState.changedRemove = layerState.changedRemove =
camera.changed?.addEventListener(cameraChanged); camera.changed?.addEventListener(cameraChanged);
parts.selection.installInteraction(viewer); parts.selection.installInteraction(viewer);
return added;
}, },
enable() { enable() {
@@ -64,6 +65,7 @@ export function createLifecycle({
disable() { disable() {
layerState.enabled = false; layerState.enabled = false;
services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID); services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID);
services.credits?.hideGooglePlacesCredit?.(layerState.viewer, LAYER_ID);
parts.namedMarkers?.hide(); parts.namedMarkers?.hide();
releaseCameraSensitivity(layerState.viewer?.camera, LAYER_ID); releaseCameraSensitivity(layerState.viewer?.camera, LAYER_ID);
layerState.contextAnchor = null; layerState.contextAnchor = null;
@@ -2,9 +2,9 @@ import * as Cesium from 'cesium';
import { import {
MILITARY_LABEL_CAP, MILITARY_LABEL_CAP,
MILITARY_POINT_CAP, MILITARY_POINT_CAP,
} from '../../data/militaryNames.js'; } from '../../../../data/militaryNames.js';
import { LAYER_ID, COLOR_BY_CLASS } from './policy.js'; import { LAYER_ID, COLOR_BY_CLASS } from '../../policy.js';
import { createInfrastructureOverlayEntry } from '../../data/infrastructureOverlayEntry.js'; import { createInfrastructureOverlayEntry } from '../../../../data/infrastructureOverlayEntry.js';
/** Bounded point markers; the shared overlay owns text projection and decluttering. */ /** Bounded point markers; the shared overlay owns text projection and decluttering. */
export function createNamedMarkers({ state, services, parts, overlayHost }) { 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++) { for (let index = 0; index < ordered.length; index++) {
const marker = ordered[index]; const marker = ordered[index];
const point = marker.point; 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; if (!point.show) continue;
const projected = Cesium.SceneTransforms.worldToWindowCoordinates( const projected = Cesium.SceneTransforms.worldToWindowCoordinates(
viewer.scene, viewer.scene,
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium'; 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({ export function createRendering({
state: layerState, state: layerState,
@@ -154,6 +154,16 @@ export function createRendering({
return selected ? [...rendered, selected] : rendered; 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 } = {}) { function renderRecords({ claimSelection = false } = {}) {
// Context navigation can select another layer without a canvas click. // Context navigation can select another layer without a canvas click.
// A delayed floor/data repaint must not steal that newer selection back. // 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); else services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID);
syncPlacesCredit(layerState.records);
const records = renderableRecords(); const records = renderableRecords();
const keys = new Map( const keys = new Map(
records.map((record) => [record.id, renderKey(record)]), records.map((record) => [record.id, renderKey(record)]),
@@ -257,7 +268,7 @@ export function createRendering({
Cesium.Cartesian3.fromDegrees(lon, lat), Cesium.Cartesian3.fromDegrees(lon, lat),
), ),
width: 2, width: 2,
material: color.withAlpha(0.85), material: new Cesium.ColorMaterialProperty(color.withAlpha(0.85)),
clampToGround: true, clampToGround: true,
classificationType: Cesium.ClassificationType.BOTH, classificationType: Cesium.ClassificationType.BOTH,
}, },
@@ -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,
);
});
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js'; import { isPointerFree } from '../../../../data/inputOwnership.js';
import { LAYER_ID } from './policy.js'; import { LAYER_ID } from '../../policy.js';
export function createSelection({ export function createSelection({
state: layerState, state: layerState,
+659
View File
@@ -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 };
}
@@ -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);
});
@@ -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;
}
@@ -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();
});
+3 -3
View File
@@ -2,9 +2,9 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import * as context from '../../data/contextStore.js'; import * as context from '../../data/contextStore.js';
import { createSelection } from './selection.js'; import { createSelection } from './render/cesium/selection.js';
import { createRendering } from './rendering.js'; import { createRendering } from './render/cesium/rendering.js';
import { createNamedMarkers } from './namedMarkers.js'; import { createNamedMarkers } from './render/cesium/namedMarkers.js';
function harness(t) { function harness(t) {
const previousWindow = globalThis.window; const previousWindow = globalThis.window;
@@ -1,8 +1,8 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as C from 'cesium'; import * as C from 'cesium';
import { createRendering } from './rendering.js'; import { createRendering } from './render/cesium/rendering.js';
import { createLifecycle } from './lifecycle.js'; import { createLifecycle } from './render/cesium/lifecycle.js';
function harness() { function harness() {
const ring = [[-97, 30], [-96.99, 30], [-96.99, 30.01], [-97, 30]]; const ring = [[-97, 30], [-96.99, 30], [-96.99, 30.01], [-97, 30]];
+1 -1
View File
@@ -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 { expandApplicationHtml } from '../../build/application-html.js';
import { readStylesheet } from '../testSupport/readStylesheet.mjs'; import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import test from 'node:test'; import test from 'node:test';
+2 -2
View File
@@ -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 () => { test('named A+B to B to A+B preserves canonical identity, parent title and selection', async () => {
const C = await import('cesium'); const C = await import('cesium');
const { createRendering } = const { createRendering } =
await import('../layers/installations/rendering.js'); await import('../layers/installations/render/cesium/rendering.js');
const aliases = new Map(); const aliases = new Map();
const named = (n, footprint, areaM2) => const named = (n, footprint, areaM2) =>
record(String(n * 10 + 2), footprint, { 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 () => { test('cold names reconcile selection through enrichment, identical groups at another zoom and wide-point handoff', async () => {
const C = await import('cesium'); const C = await import('cesium');
const { createRendering } = const { createRendering } =
await import('../layers/installations/rendering.js'); await import('../layers/installations/render/cesium/rendering.js');
const { retainNamedHandoff } = const { retainNamedHandoff } =
await import('../layers/installations/ingestion.js'); await import('../layers/installations/ingestion.js');
const aliases = new Map(); const aliases = new Map();