diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c6fb1f967..c90ac4412 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2702,6 +2702,7 @@ "src/layers/installations/render/navara.js", "src/layers/bikeshare/render/navara.js", "src/layers/installations/render/navaraGeometry.js", + "src/layers/installations/render/navaraOutlineBatch.js", "src/layers/firms/rectangle.js", "src/layers/firms/render/cesium.js", "src/layers/radio/render/cesium.js", diff --git a/src/globe/navara/overlay.js b/src/globe/navara/overlay.js index 8013d0e98..1b3ce9796 100644 --- a/src/globe/navara/overlay.js +++ b/src/globe/navara/overlay.js @@ -55,7 +55,9 @@ class GevOverlayEffectDesc extends EffectDesc { class GevPreSharpenOverlayEffectDesc extends GevOverlayEffectDesc { static key = PRE_SHARPEN_KEY; - static insertAfter = ['toneMapping']; + // Ground classifiers also insert after tone mapping. Anchor to the next + // stage so glyphs stay above them regardless of layer activation order. + static insertAfter = []; static insertBefore = [ 'gevSharpen', 'smaa', diff --git a/src/layers/earthquakes/render/navara.js b/src/layers/earthquakes/render/navara.js index b61d186c1..4e0bb9825 100644 --- a/src/layers/earthquakes/render/navara.js +++ b/src/layers/earthquakes/render/navara.js @@ -199,8 +199,16 @@ function buildState(discs) { function groundEffectClass(EffectDesc, NavaraEffect) { return class GroundEffectDesc extends EffectDesc { static key = KEY; - static insertAfter = ['toneMapping']; - static insertBefore = ['gevSharpen', 'smaa', 'fxaa', 'gevOverlay', 'final']; + static insertAfter = ['gevRecentImagery', 'toneMapping']; + static insertBefore = [ + 'gevFirmsGround', + 'gevPreSharpenOverlay', + 'gevSharpen', + 'smaa', + 'fxaa', + 'gevOverlay', + 'final', + ]; constructor(view, ctx, config) { super(view, ctx, config); this.owner = config[KEY].owner; diff --git a/src/layers/firms/render/navara.js b/src/layers/firms/render/navara.js index 53937c814..cc8c03f29 100644 --- a/src/layers/firms/render/navara.js +++ b/src/layers/firms/render/navara.js @@ -214,12 +214,16 @@ function buildState(cells) { function groundEffectClass(EffectDesc, NavaraEffect) { return class FirmsGroundEffectDesc extends EffectDesc { static key = KEY; - static insertAfter = ['toneMapping']; + static insertAfter = [ + 'gevEarthquakeGround', + 'gevRecentImagery', + 'toneMapping', + ]; static insertBefore = [ + 'gevPreSharpenOverlay', 'gevSharpen', 'smaa', 'fxaa', - 'gevPreSharpenOverlay', 'gevOverlay', 'final', ]; diff --git a/src/layers/installations/render/navara.js b/src/layers/installations/render/navara.js index 3eb039bfd..7de8100f4 100644 --- a/src/layers/installations/render/navara.js +++ b/src/layers/installations/render/navara.js @@ -11,6 +11,7 @@ import { createInstallationDrapedMesh, createInstallationOutlineDrapedMesh, } from './navaraGeometry.js'; +import { batchInstallationOutlines } from './navaraOutlineBatch.js'; const registeredViews = new WeakSet(); const KEY = 'gevInstallationsDrape'; @@ -131,7 +132,9 @@ export function createInstallationsNavaraRenderer( const contextObjects = new Map(); const namedRecords = new Map(); const selectionMeshes = new Set(); - const outlinesById = new Map(); + const outlineMeshes = new Set(); + let outlineRecords = [], + outlineWidthM = null; let drapeHandle = null; let visible = false; let fillRecord = null; @@ -228,18 +231,17 @@ export function createInstallationsNavaraRenderer( 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]), + const desired = records.filter( + (record) => !record.pointOnly && !record.namedArea, ); - 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; + if ( + outlineWidthM === widthM && + desired.length === outlineRecords.length && + desired.every((record, index) => record === outlineRecords[index]) + ) + return; + const next = []; + for (const record of desired) { const lines = record.outlineLines || (record.footprint ? [record.footprint] : []); const meshes = lines @@ -254,17 +256,17 @@ export function createInstallationsNavaraRenderer( }), ) .filter(Boolean); - const owned = new Set(meshes); - for (const mesh of owned) owner.meshes.add(mesh); - outlinesById.set(id, { record, widthM, meshes: owned }); + next.push(...meshes); } - syncDrapeEffect(); + replaceOwnedMeshes(outlineMeshes, batchInstallationOutlines(next)); + outlineRecords = desired; + outlineWidthM = widthM; } function clearOutlines() { - for (const entry of outlinesById.values()) - replaceOwnedMeshes(entry.meshes, []); - outlinesById.clear(); + replaceOwnedMeshes(outlineMeshes, []); + outlineRecords = []; + outlineWidthM = null; } function registerContext(record) { @@ -643,7 +645,7 @@ export function createInstallationsNavaraRenderer( selection.destroy(); rendering.clearRendered(); namedMarkers.destroy(); - drapeHandle?.destroy(); + drapeHandle?.delete(); drapeHandle = null; normalSet.destroy(); selectedSet.destroy(); diff --git a/src/layers/installations/render/navara.test.mjs b/src/layers/installations/render/navara.test.mjs index b8900689c..cb962ceb6 100644 --- a/src/layers/installations/render/navara.test.mjs +++ b/src/layers/installations/render/navara.test.mjs @@ -42,6 +42,7 @@ test('selected installation fill is reused and removed with its scene resources' }; }, removeEntityContextsForLayer() {}, + clearSelectedEntityContextForLayer() {}, }, picking: { registerPickOwner() {}, unregisterPickOwner() {} }, }; @@ -64,13 +65,19 @@ test('selected installation fill is reused and removed with its scene resources' render: { request() {} }, }; let effectConfig; + let deletedEffects = 0; const view = { canvas: { clientHeight: 800 }, camera: { raw: { fov: 60 } }, registerEffect() {}, addEffect(config) { effectConfig = config; - return { update() {}, destroy() {} }; + return { + update() {}, + delete() { + deletedEffects++; + }, + }; }, }; let meshCount = 0; @@ -88,6 +95,8 @@ test('selected installation fill is reused and removed with its scene resources' installationSourceLabel: () => 'OpenStreetMap', }, namedMarkers: { sync() {}, enable() {}, hide() {}, destroy() {} }, + viewport: { clearUnavailableRetry() {} }, + ingestion: { setInstallationStatus() {} }, }; const renderer = createInstallationsNavaraRenderer(engine, { state, @@ -144,4 +153,7 @@ test('selected installation fill is reused and removed with its scene resources' 'clearing selection preserves the ordinary outline', ); assert.equal(sets.length, 3); + renderer.lifecycle.methods.destroy(); + assert.equal(deletedEffects, 1, 'destroy deletes the public effect handle'); + assert.equal(effectConfig.gevInstallationsDrape.owner.meshes.size, 0); }); diff --git a/src/layers/installations/render/navaraOutlineBatch.js b/src/layers/installations/render/navaraOutlineBatch.js new file mode 100644 index 000000000..6da5aff91 --- /dev/null +++ b/src/layers/installations/render/navaraOutlineBatch.js @@ -0,0 +1,63 @@ +import { Matrix4 } from 'three'; +import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; + +// Each DrapedMesh needs three stencil draws. Consecutive, equally coloured +// volumes with disjoint XY bounds can share those draws without changing +// translucent overlap order. Keep coordinates local to preserve precision. +// Consumes the input meshes; the returned meshes own the surviving resources. +export function batchInstallationOutlines(meshes) { + const groups = []; + for (const mesh of meshes) { + const group = groups.at(-1); + let geometry; + if ( + group && + mesh.material.color.equals(group.mesh.material.color) && + mesh.material.opacity === group.mesh.material.opacity + ) { + const relative = new Matrix4().multiplyMatrices( + group.inverse, + mesh.matrix, + ); + if (Math.hypot(...relative.elements.slice(12, 15)) <= 100_000) { + geometry = mesh.geometry.clone().applyMatrix4(relative); + geometry.computeBoundingBox(); + const b = geometry.boundingBox; + if ( + group.bounds.every( + (a) => + b.min.x > a.max.x || + b.max.x < a.min.x || + b.min.y > a.max.y || + b.max.y < a.min.y, + ) + ) { + group.geometries.push(geometry); + group.bounds.push(b); + group.mesh.userData.installationIds.push( + mesh.userData.installationId, + ); + mesh.geometry.dispose(); + mesh.material.dispose(); + continue; + } + geometry.dispose(); + } + } + mesh.geometry.computeBoundingBox(); + mesh.userData.installationIds = [mesh.userData.installationId]; + groups.push({ + mesh, + inverse: mesh.matrix.clone().invert(), + geometries: [mesh.geometry], + bounds: [mesh.geometry.boundingBox], + }); + } + return groups.map(({ mesh, geometries }) => { + if (geometries.length > 1) { + mesh.geometry = mergeGeometries(geometries); + for (const geometry of geometries) geometry.dispose(); + } + return mesh; + }); +} diff --git a/src/layers/installations/render/navaraOutlineBatch.test.mjs b/src/layers/installations/render/navaraOutlineBatch.test.mjs new file mode 100644 index 000000000..48f61db73 --- /dev/null +++ b/src/layers/installations/render/navaraOutlineBatch.test.mjs @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { BoxGeometry, Mesh, MeshBasicMaterial, Vector3 } from 'three'; +import { batchInstallationOutlines } from './navaraOutlineBatch.js'; + +function volume(id, x, { color = '#ff8800', opacity = 0.85, y = 0 } = {}) { + const mesh = new Mesh( + new BoxGeometry(10, 10, 40000), + new MeshBasicMaterial({ color, opacity }), + ); + mesh.matrix.makeTranslation(x, y, 0); + mesh.userData.installationId = id; + return mesh; +} + +test('disjoint equal-colour outlines share a stencil volume without moving their vertices', () => { + const meshes = [ + volume('a', 6_000_000), + volume('b', 6_000_100), + volume('c', 6_000_200), + ]; + const positions = meshes.flatMap((m) => { + const a = m.geometry.attributes.position; + return Array.from({ length: a.count }, (_, i) => + new Vector3().fromBufferAttribute(a, i).applyMatrix4(m.matrix), + ); + }); + const geometryDisposals = [], + materialDisposals = []; + for (const m of meshes) { + m.geometry.addEventListener('dispose', () => + geometryDisposals.push(m.userData.installationId), + ); + m.material.addEventListener('dispose', () => + materialDisposals.push(m.userData.installationId), + ); + } + const result = batchInstallationOutlines(meshes); + assert.equal(result.length, 1); + assert.deepEqual(result[0].userData.installationIds, ['a', 'b', 'c']); + const a = result[0].geometry.attributes.position; + assert.equal(a.count, positions.length); + for (let i = 0; i < a.count; i++) + assert.ok( + new Vector3() + .fromBufferAttribute(a, i) + .applyMatrix4(result[0].matrix) + .distanceTo(positions[i]) < 1e-5, + ); + assert.deepEqual(geometryDisposals.sort(), ['a', 'b', 'c']); + assert.deepEqual(materialDisposals.sort(), ['b', 'c']); + result[0].geometry.dispose(); + result[0].material.dispose(); + assert.deepEqual(materialDisposals.sort(), ['a', 'b', 'c']); +}); + +test('overlapping volumes, different materials and distant origins retain separate draws and order', () => { + for (const second of [ + volume('b', 5), + volume('b', 100, { color: '#00ff00' }), + volume('b', 100, { opacity: 0.5 }), + volume('b', 100_001), + ]) { + const first = volume('a', 0), + result = batchInstallationOutlines([first, second]); + assert.deepEqual(result, [first, second]); + for (const mesh of result) { + mesh.geometry.dispose(); + mesh.material.dispose(); + } + } + const meshes = [ + volume('a', 0), + volume('b', 100, { color: '#00ff00' }), + volume('c', 200), + ]; + assert.deepEqual(batchInstallationOutlines(meshes), meshes); + for (const mesh of meshes) { + mesh.geometry.dispose(); + mesh.material.dispose(); + } +}); + +test('batch admission compares against every prior volume, including rotated bounds', () => { + const meshes = [volume('a', 0), volume('b', 100), volume('c', 0)]; + meshes[2].matrix.makeRotationZ(Math.PI / 4); + const result = batchInstallationOutlines(meshes); + assert.equal(result.length, 2); + assert.deepEqual( + result.map((m) => m.userData.installationIds), + [['a', 'b'], ['c']], + ); + for (const mesh of result) { + mesh.geometry.dispose(); + mesh.material.dispose(); + } +}); diff --git a/src/layers/recentImagery/render/navaraGround.js b/src/layers/recentImagery/render/navaraGround.js index f9e6b3873..291fd9868 100644 --- a/src/layers/recentImagery/render/navaraGround.js +++ b/src/layers/recentImagery/render/navaraGround.js @@ -69,10 +69,12 @@ export function createImageryGround( static key = KEY; static insertAfter = ['toneMapping']; static insertBefore = [ + 'gevEarthquakeGround', + 'gevFirmsGround', + 'gevPreSharpenOverlay', 'gevSharpen', 'smaa', 'fxaa', - 'gevPreSharpenOverlay', 'gevOverlay', 'final', ]; diff --git a/src/layers/shared/render/navaraGroundGeometry.js b/src/layers/shared/render/navaraGroundGeometry.js index 080fe0524..ef16c2087 100644 --- a/src/layers/shared/render/navaraGroundGeometry.js +++ b/src/layers/shared/render/navaraGroundGeometry.js @@ -19,10 +19,10 @@ export function viewFrame(frame, matrix, out) { /** Conservative clip bounds, including a camera inside/crossing the volume. */ export function groundClipBounds(frame, view, projection, out) { const p = projection.elements; - let x0 = 1, - y0 = 1, - x1 = -1, - y1 = -1, + let x0 = Infinity, + y0 = Infinity, + x1 = -Infinity, + y1 = -Infinity, front = 0, behind = 0; for (let i = 0; i < 8; i++) { diff --git a/src/layers/shared/render/navaraGroundGeometry.test.mjs b/src/layers/shared/render/navaraGroundGeometry.test.mjs new file mode 100644 index 000000000..37dba30f7 --- /dev/null +++ b/src/layers/shared/render/navaraGroundGeometry.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PerspectiveCamera } from 'three'; +import { groundClipBounds } from './navaraGroundGeometry.js'; + +test('ground volumes wholly outside a screen edge do not enter the candidate grid', () => { + const camera = new PerspectiveCamera(60, 1.6, 1, 1000); + const frame = { radius: 1, floor: -1, ceiling: 1 }; + for (const [x, y] of [ + [100, 0], + [-100, 0], + [0, 100], + [0, -100], + ]) { + const view = new Float64Array([x, y, -10, 1, 0, 0, 0, 1, 0, 0, 0, 1]); + assert.deepEqual( + [ + ...groundClipBounds( + frame, + view, + camera.projectionMatrix, + new Float64Array(4), + ), + ], + [2, 2, 2, 2], + `offscreen volume at ${x}, ${y}`, + ); + } +}); diff --git a/src/layers/shared/render/navaraGroundOrder.test.mjs b/src/layers/shared/render/navaraGroundOrder.test.mjs new file mode 100644 index 000000000..e1e60c863 --- /dev/null +++ b/src/layers/shared/render/navaraGroundOrder.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; + +// EffectDesc gives insertAfter priority, then tries insertBefore. Check the +// actual renderer declarations under every activation order, including the +// minimal look, whose pipeline has no toneMapping anchor. +const declarations = [ + ['gevRecentImagery', '../../recentImagery/render/navaraGround.js'], + ['gevEarthquakeGround', '../../earthquakes/render/navara.js'], + ['gevFirmsGround', '../../firms/render/navara.js'], + ['gevPreSharpenOverlay', '../../../globe/navara/overlay.js'], +]; +const specs = await Promise.all( + declarations.map(async ([key, path]) => { + let source = await fs.readFile(new URL(path, import.meta.url), 'utf8'); + if (key === 'gevPreSharpenOverlay') + source = source.split('class GevPreSharpenOverlayEffectDesc')[1]; + const anchors = (name) => + [ + ...source + .match(new RegExp(`static ${name} = \\[([^\\]]*)\\]`))[1] + .matchAll(/'([^']+)'/g), + ].map((m) => m[1]); + return { + key, + after: anchors('insertAfter'), + before: anchors('insertBefore'), + }; + }), +); +const permutations = (items) => + items.length + ? items.flatMap((item, i) => + permutations(items.filter((_, j) => i !== j)).map((rest) => [ + item, + ...rest, + ]), + ) + : [[]]; + +test('all ground/glyph activation orders preserve imagery, classification and marker stages', () => { + for (const toneMapping of [false, true]) + for (const activation of permutations(specs)) { + const order = [ + 'mrt', + ...(toneMapping ? ['toneMapping'] : []), + 'gevSharpen', + 'smaa', + 'gevOverlay', + 'final', + ]; + for (const spec of activation) { + const after = spec.after.find((key) => order.includes(key)); + const before = spec.before.find((key) => order.includes(key)); + const index = after + ? order.indexOf(after) + 1 + : before + ? order.indexOf(before) + : order.length; + order.splice(index, 0, spec.key); + } + assert.deepEqual( + order.filter((key) => specs.some((s) => s.key === key)), + declarations.map(([key]) => key), + `${toneMapping}: ${activation.map((s) => s.key)}`, + ); + } +});