mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(installations): add Navara renderer
This commit is contained in:
1 parent
965a82144c
commit
1a694baf5a
27 files changed
+1426
-47
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,8 +34,13 @@ export const OPENMAPTILES_CREDIT = {
|
||||
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>',
|
||||
};
|
||||
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 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 ────────────────────────────────────────────────
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+6
-4
@@ -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;
|
||||
+6
-4
@@ -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,
|
||||
+13
-2
@@ -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,
|
||||
},
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
+2
-2
@@ -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,
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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]];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user