From a3152ab719ce94e26d8351143197bd4ad7b84180 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 13:42:07 +0400 Subject: [PATCH] Add depth-classified earthquakes on Navara Match native ground footprints, encoded alpha and source ordering on real terrain and photoreal tiles while excluding forward meshes. Cache static screen data and reduce large-disc edge work without changing GPU pixels. Add deterministic lifecycle, source/share, foreground/horizon and three-context performance proof; restore credits for available Navara feeds and retain failure history in migration evidence. --- scripts/fixtures/w2/earthquakes.mjs | 36 ++ scripts/format-scope.json | 12 +- scripts/package-boundaries.json | 5 +- src/app/navaraLayers.test.mjs | 2 + src/app/navaraMode.js | 33 +- src/app/navaraScene.test.mjs | 29 +- src/globe/navara/index.js | 8 +- src/globe/testing/contractSuite.mjs | 21 +- src/layers/earthquakes/render/navara.js | 342 ++++++++++++++++++ src/layers/earthquakes/render/navara.test.mjs | 148 ++++++++ .../earthquakes/render/navaraGeometry.js | 142 ++++++++ .../render/navaraGeometry.test.mjs | 237 ++++++++++++ src/layers/earthquakes/render/navaraGrid.js | 69 ++++ .../earthquakes/render/navaraGrid.test.mjs | 38 ++ 14 files changed, 1106 insertions(+), 16 deletions(-) create mode 100644 src/layers/earthquakes/render/navara.js create mode 100644 src/layers/earthquakes/render/navara.test.mjs create mode 100644 src/layers/earthquakes/render/navaraGeometry.js create mode 100644 src/layers/earthquakes/render/navaraGeometry.test.mjs create mode 100644 src/layers/earthquakes/render/navaraGrid.js create mode 100644 src/layers/earthquakes/render/navaraGrid.test.mjs diff --git a/scripts/fixtures/w2/earthquakes.mjs b/scripts/fixtures/w2/earthquakes.mjs index d7898924e..6060c7183 100644 --- a/scripts/fixtures/w2/earthquakes.mjs +++ b/scripts/fixtures/w2/earthquakes.mjs @@ -45,3 +45,39 @@ export function earthquakeFixture() { features, }; } + +/** Dense regional and worldwide USGS-shaped load, separate from the oracle. */ +export function earthquakePerformanceFixture(count = 500) { + let seed = 17; + const random = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed / 4294967296; + }; + return { + type: 'FeatureCollection', + features: Array.from({ length: count }, (_, i) => ({ + type: 'Feature', + id: `perf-quake-${i}`, + properties: { + mag: 2.5 + (i % 7) * 0.5, + place: `Recorded performance event ${i}`, + time: W2_EPOCH_MS - i * 1000, + }, + geometry: { + type: 'Point', + coordinates: + i < Math.min(100, count) + ? [ + -122.42 + (random() - 0.5), + 37.77 + (random() - 0.5), + [8, 70, 301][i % 3], + ] + : [ + -180 + random() * 360, + -70 + random() * 140, + [8, 70, 301][i % 3], + ], + }, + })), + }; +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5d32dbea4..9597fdf73 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1184,5 +1184,15 @@ "scripts/fixtures/w2/earthquakes.mjs", "src/tooling/w2QaComparison.test.mjs", "src/layers/earthquakes/render/cesium.js", - "src/layers/earthquakes/render/cesium.test.mjs" + "src/layers/earthquakes/render/cesium.test.mjs", + "src/layers/earthquakes/render/navara.js", + "src/layers/earthquakes/render/navaraGeometry.js", + "src/layers/earthquakes/render/navaraGeometry.test.mjs", + "src/layers/earthquakes/render/navaraGrid.js", + "src/layers/earthquakes/render/navaraGrid.test.mjs", + "src/layers/earthquakes/render/navara.test.mjs", + "src/bench/w2GroundProbe.js", + "scripts/qa-w2-ground-mesh.mjs", + "scripts/qa-w2-ground-perf.mjs", + "scripts/qa-w2-ground-equivalence.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 430d7f9ab..b2fce2320 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2695,7 +2695,10 @@ "src/celestialRing/render/cesium.js", "src/celestialRing/view.js", "src/layers/satellites/render/cesium.js", - "src/layers/earthquakes/render/cesium.js" + "src/layers/earthquakes/render/cesium.js", + "src/layers/earthquakes/render/navara.js", + "src/layers/earthquakes/render/navaraGeometry.js", + "src/layers/earthquakes/render/navaraGrid.js" ], "external": [ "hls.js", diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index ae4f397f6..19bd4a1f9 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -24,6 +24,7 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers, 'rocket-launches', 'satellites', 'ais-live-vessels', + 'earthquakes', ]) assert.equal(layerAvailableOn(id, 'navara'), true, id); assert.deepEqual( @@ -34,6 +35,7 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers, 'flights', 'military', 'local-adsb', + 'earthquakes', 'satellites', 'rocket-launches', 'ais-live-vessels', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index e1ad842aa..44b5d3dc1 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -41,15 +41,18 @@ export function navaraModeNotice(availability) { }); } -/** - * Data credits for what the shell uses without data layers: place search, - * its OpenStreetMap data and the keyless terrain. Navara lists every credit - * in one attribution card, so layer credits would only crowd it. - */ -const SHELL_CREDIT_KEYS = new Set([ +/** Static attribution for the shell and the layers available on Navara. */ +const NAVARA_CREDIT_KEYS = new Set([ 'photon-geocoder', 'openstreetmap', 'reearth-terrain', + 'opensky', + 'adsblol', + 'adsbdb', + 'aisstream', + 'celestrak', + 'launch-library-2', + 'usgs', ]); /** @@ -58,6 +61,15 @@ const SHELL_CREDIT_KEYS = new Set([ */ export async function registerNavaraLayerRenderers() { const loaders = { + earthquakes: async () => { + const { createEarthquakesNavaraRenderer } = + await import('../layers/earthquakes/render/navara.js'); + return (engine, options) => + createEarthquakesNavaraRenderer(engine, { + ...options, + ...navaraViews.get(engine), + }); + }, 'rocket-launches': async () => { const { createLaunchesNavaraRenderer } = await import('../layers/launches/render/navara.js'); @@ -124,7 +136,12 @@ export async function createNavaraScene({ }); defer(() => globe.destroy()); const { engine } = globe; - navaraViews.set(engine, { view: globe.view, MeshDesc: globe.MeshDesc }); + navaraViews.set(engine, { + view: globe.view, + MeshDesc: globe.MeshDesc, + EffectDesc: globe.EffectDesc, + NavaraEffect: globe.NavaraEffect, + }); defer(() => navaraViews.delete(engine)); // The layers drawn on Navara (their render/navara.js), before the catalog // asks the registry which layers this engine shows. @@ -155,7 +172,7 @@ export async function createNavaraScene({ // Navara's attribution card. registerDataCredits( engine, - (credits ?? DATA_CREDITS).filter(({ key }) => SHELL_CREDIT_KEYS.has(key)), + (credits ?? DATA_CREDITS).filter(({ key }) => NAVARA_CREDIT_KEYS.has(key)), ); return { viewer: null, diff --git a/src/app/navaraScene.test.mjs b/src/app/navaraScene.test.mjs index be543c4a5..d520a93b9 100644 --- a/src/app/navaraScene.test.mjs +++ b/src/app/navaraScene.test.mjs @@ -131,11 +131,11 @@ test('the Navara scene builds the engine on the app container and no viewer', as assert.equal(run.loads[0].search, '?engine=navara&look=photoreal'); assert.ok(run.scene.operations.searchAndFlyTo); assert.equal(globalThis.window.__GOOGLE_MAPS_API_KEY__, 'google-test'); - // Data layers are off: only the credits of what the shell itself uses - // (place search, its OSM data, keyless terrain) go to the engine's credits. + // Match the native static attribution policy for available data layers, + // even before a layer is enabled. assert.deepEqual( run.engine.fake.credits.map(({ html }) => html), - ['Search credit', 'OSM credit', 'Terrain credit'], + ['Flights credit', 'Search credit', 'OSM credit', 'Terrain credit'], ); }); @@ -171,3 +171,26 @@ test('the Navara scene tears down the controller, then the globe', async (t) => assert.equal(isRenderGovernorInstalled(run.engine), false); assert.equal(view.__GOOGLE_MAPS_API_KEY__, undefined); }); + +test('Navara keeps attribution for every available W1/W2 source and excludes unavailable data', async (t) => { + const keys = [ + 'opensky', + 'adsblol', + 'adsbdb', + 'aisstream', + 'celestrak', + 'launch-library-2', + 'usgs', + 'photon-geocoder', + 'openstreetmap', + 'reearth-terrain', + ]; + const run = await startScene({ + credits: [...keys, 'firms'].map((key) => ({ key, html: `Credit ${key}` })), + }); + t.after(run.teardown); + assert.deepEqual( + run.engine.fake.credits.map(({ html }) => html), + keys.map((key) => `Credit ${key}`), + ); +}); diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 16ddcda88..579ff6c98 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -2,7 +2,11 @@ // the product way (viewOptions.js) and the GlobeEngine on it. Load it with a // dynamic import() so Navara stays out of the Cesium-mode bundle. -import ThreeView, { MeshDesc } from '@navaramap/three'; +import ThreeView, { + MeshDesc, + EffectDesc, + Effect as NavaraEffect, +} from '@navaramap/three'; import * as geo from '../../geo/index.js'; import { DefaultPlugin } from '@navaramap/three-default-plugin'; import { createNavaraEngine } from './createNavaraEngine.js'; @@ -187,6 +191,8 @@ export async function createNavaraGlobe({ view, engine, MeshDesc, + EffectDesc, + NavaraEffect, destroy() { if (destroyed) return; destroyed = true; diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 1a84d9619..2ac47f97e 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -901,10 +901,27 @@ export function createContractSuite( ), `a pickable: false sprite was picked (${JSON.stringify(onQuiet.picked?.id)})`, ); - const onLine = await clickAt(at(20, 9)); + const linePosition = at(20, 9); + const lineCameraBefore = cartesian3.clone( + engine.camera.pose().position, + ); + const onLine = await clickAt(linePosition); ensure( onLine.picked?.id === 'line-1', - `a click on the line picked ${JSON.stringify(onLine.picked?.id)}`, + `a click on the line picked ${JSON.stringify(onLine.picked?.id)}; ${ + onLine.picked?.id === 'line-1' + ? '' + : JSON.stringify({ + event: onLine.position, + projectedNow: screen(linePosition), + directNow: engine.picker.pick(onLine.position)?.id, + drilled: (onLine.drilled ?? []).map(({ id }) => id), + cameraMovedM: cartesian3.distance( + lineCameraBefore, + engine.camera.pose().position, + ), + }) + }`, ); const target = screen(a); const movesBefore = moves.length; diff --git a/src/layers/earthquakes/render/navara.js b/src/layers/earthquakes/render/navara.js new file mode 100644 index 000000000..4a9888033 --- /dev/null +++ b/src/layers/earthquakes/render/navara.js @@ -0,0 +1,342 @@ +// Classify the real visible terrain/Google-tiles surface in a public +// Navara post-effect. Native ground colour blends in display space; doing +// it explicitly preserves translucent colours and overlap order. +import { Effect, EffectAttribute } from 'postprocessing'; +import { + DataTexture, + FloatType, + RGBAFormat, + NearestFilter, + Matrix4, + Uniform, + RGBADepthPacking, +} from 'three'; +import * as geo from '../../../geo/index.js'; +import { + DISC_EDGES, + discFrame, + viewFrame, + discClipBounds, +} from './navaraGeometry.js'; +import { createDiscGrid, GRID_COLS, GRID_ROWS } from './navaraGrid.js'; +const KEY = 'gevEarthquakeGround'; +const registeredViews = new WeakSet(); +const fragmentShader = /* glsl */ ` +uniform sampler2D tAllDepth; +uniform sampler2D tSurfaceDepth; +uniform sampler2D tEdges; +uniform sampler2D tFrames; +uniform sampler2D tGrid; +uniform sampler2D tCandidates; +uniform vec2 uEdgeSize; +uniform vec2 uFrameSize; +uniform vec2 uCandidateSize; +uniform mat4 uInverseProjection; +uniform float uLogDepthFC; +vec4 atSlot(sampler2D image,vec2 size,float slot) { + return texture2D(image,(vec2(mod(slot,size.x),floor(slot/size.x))+0.5)/size); +} +vec3 toDisplay(vec3 c) { + return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c)); +} +vec3 toLinear(vec3 c) { + return mix(c/12.92,pow((c+0.055)/1.055,vec3(2.4)),step(vec3(0.04045),c)); +} +void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) { + outputColor=inputColor; + float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv)); + float distance=exp2(2.0*depth/uLogDepthFC)-1.0; + vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0); + vec3 point=ray.xyz*(distance/-ray.z); + // Derivatives must be evaluated before divergent cell/feature branches. + // Inside the candidate loop adjacent pixels may be testing different IDs. + vec3 pointDx=dFdx(point),pointDy=dFdy(point); + if(depth>=1.0) return; + vec2 cell=clamp(floor(uv*vec2(${GRID_COLS}.0,${GRID_ROWS}.0)),vec2(0.0),vec2(${GRID_COLS - 1}.0,${GRID_ROWS - 1}.0)); + vec2 range=texture2D(tGrid,(cell+0.5)/vec2(${GRID_COLS}.0,${GRID_ROWS}.0)).xy; + if(range.y<0.5) return; + float surface=texture2D(tSurfaceDepth,uv).r; + // Public MRT depth excludes forward custom meshes, which are not ground. + if(depthnorth.w) continue; + float coverage=1.0; + // A convex boundary intersects a ray from its centre on exactly one + // edge. For large discs, binary-search that edge and its two neighbours; + // all other edges lie beyond the one-pixel AA band. Small/subpixel discs + // retain the full supporting-plane coverage calculation. + bool large=center.w>128.0*max(length(pointDx),length(pointDy)); + int first=0; + if(large) { + float angle=mod(atan(local.x,-local.y)-up.w+6.28318530718,6.28318530718); + int low=0,high=${DISC_EDGES - 1}; + for(int step=0;step<6;step++) { + int middle=(low+high+1)/2; + float start=atSlot(tEdges,uEdgeSize,id*${DISC_EDGES}.0+float(middle)).w; + if(angle>=start) low=middle;else high=middle-1; + } + first=low-1; + } + int count=large?3:${DISC_EDGES}; + for(int k=0;k edges.data.set(frame.edges, i * DISC_EDGES * 4)); + const state = { + count: discs.length, + edges, + packed, + gridTexture, + get candidates() { + return candidates; + }, + update(camera, ctx) { + const input = ctx.getInputBuffer(); + const view = camera.matrixWorldInverse.elements; + const projection = camera.projectionMatrix.elements; + let changed = input.width !== lastWidth || input.height !== lastHeight; + for (let i = 0; i < 16 && !changed; i++) + changed = + view[i] !== lastView[i] || projection[i] !== lastProjection[i]; + // All features are static. Depth still refreshes each frame, but their + // screen index/coordinates only change with the camera or viewport. + if (!changed) return; + lastView.set(view); + lastProjection.set(projection); + lastWidth = input.width; + lastHeight = input.height; + frames.forEach((frame, i) => { + viewFrame(frame, camera.matrixWorldInverse, scratch); + discClipBounds(frame, scratch, camera.projectionMatrix, bounds); + if (bounds[0] <= 1) { + bounds[0] -= 2 / Math.max(1, input.width); + bounds[2] += 2 / Math.max(1, input.width); + bounds[1] -= 2 / Math.max(1, input.height); + bounds[3] += 2 / Math.max(1, input.height); + } + grid.bounds.set(bounds, i * 4); + const offset = i * 20; + packed.data.set( + [scratch[0], scratch[1], scratch[2], frame.radius], + offset, + ); + packed.data.set( + [scratch[3], scratch[4], scratch[5], frame.floor], + offset + 4, + ); + packed.data.set( + [scratch[6], scratch[7], scratch[8], frame.ceiling], + offset + 8, + ); + packed.data.set( + [scratch[9], scratch[10], scratch[11], frame.phase], + offset + 12, + ); + const d = discs[i]; + packed.data.set( + [d.color.red, d.color.green, d.color.blue, d.fillAlpha], + offset + 16, + ); + }); + const total = grid.pack(); + if (candidates.data.length / 4 < total) { + candidates.texture.dispose(); + candidates = storage(Math.max(total, (2 * candidates.data.length) / 4)); + } + for (let i = 0; i < total; i++) candidates.data[i * 4] = grid.indices[i]; + for (let i = 0; i < grid.counts.length; i++) { + gridData[i * 4] = grid.offsets[i]; + gridData[i * 4 + 1] = grid.counts[i]; + } + packed.texture.needsUpdate = true; + gridTexture.needsUpdate = true; + candidates.texture.needsUpdate = true; + }, + dispose() { + edges.texture.dispose(); + packed.texture.dispose(); + gridTexture.dispose(); + candidates.texture.dispose(); + }, + }; + return state; +} + +function groundEffectClass(EffectDesc, NavaraEffect) { + return class GroundEffectDesc extends EffectDesc { + static key = KEY; + static insertAfter = ['toneMapping']; + static insertBefore = ['gevSharpen', 'smaa', 'fxaa', 'gevOverlay', 'final']; + constructor(view, ctx, config) { + super(view, ctx, config); + this.owner = config[KEY].owner; + } + createPass() { + this.effect = new Effect('GevEarthquakeGround', fragmentShader, { + // Keep a separate pass: inputColor is the already tone-mapped frame. + attributes: EffectAttribute.CONVOLUTION, + uniforms: new Map([ + ['tAllDepth', new Uniform(null)], + ['tSurfaceDepth', new Uniform(null)], + ['tEdges', new Uniform(null)], + ['tFrames', new Uniform(null)], + ['tGrid', new Uniform(null)], + ['tCandidates', new Uniform(null)], + ['uEdgeSize', new Uniform([1, 1])], + ['uFrameSize', new Uniform([1, 1])], + ['uCandidateSize', new Uniform([1, 1])], + ['uInverseProjection', new Uniform(new Matrix4())], + ['uLogDepthFC', new Uniform(1)], + ]), + }); + return new NavaraEffect(this.view.camera.raw, this.effect); + } + update() { + const state = this.owner.state, + camera = this.view.camera.raw, + mrt = this.find('mrt'); + if (!this.effect || !camera || !state) return; + if (mrt?.depthBufferPacking !== RGBADepthPacking) + throw new Error( + 'Earthquake classification requires packed scene depth', + ); + state.update(camera, this.ctx); + const u = this.effect.uniforms; + u.get('tAllDepth').value = mrt.depthBuffer; + u.get('tSurfaceDepth').value = this.ctx.getRenderTarget().depthTexture; + u.get('tEdges').value = state.edges.texture; + u.get('uEdgeSize').value = [state.edges.width, state.edges.height]; + u.get('tFrames').value = state.packed.texture; + u.get('uFrameSize').value = [state.packed.width, state.packed.height]; + u.get('tGrid').value = state.gridTexture; + u.get('tCandidates').value = state.candidates.texture; + u.get('uCandidateSize').value = [ + state.candidates.width, + state.candidates.height, + ]; + u.get('uInverseProjection').value.copy(camera.projectionMatrixInverse); + u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1); + } + onDestroy() { + this.effect?.dispose(); + super.onDestroy(); + } + }; +} + +export function createEarthquakesNavaraRenderer( + engine, + { view, EffectDesc, NavaraEffect } = {}, +) { + if (!view?.camera || !engine?.render || !EffectDesc || !NavaraEffect) + throw new TypeError('Earthquakes require a Navara view'); + if (!registeredViews.has(view)) { + view.registerEffect(KEY, groundEffectClass(EffectDesc, NavaraEffect)); + registeredViews.add(view); + } + let destroyed = false, + visible = false, + rows = [], + handle = null; + const owner = { state: buildState([]) }; + const sync = () => { + const show = visible && owner.state.count > 0; + if (show && !handle) + handle = view.addEffect({ [KEY]: { owner }, visible: true }); + else handle?.update({ visible: show }); + }; + return { + kind: 'navara', + setVisible(show) { + if (destroyed) return; + visible = show !== false; + sync(); + engine.render.request('earthquake-visibility'); + }, + replace(discs) { + if (destroyed) return; + const nextRows = discs.map((d) => { + const place = geo.cartographic.fromCartesian(d.position); + return { + id: d.usgsId ?? null, + mag: d.mag, + place: d.place, + time: d.time, + depth: d.depthKm, + lat: place ? geo.math.toDegrees(place.latitude) : null, + lon: place ? geo.math.toDegrees(place.longitude) : null, + }; + }); + const next = buildState(discs), + previous = owner.state; + owner.state = next; + rows = nextRows; + sync(); + previous.dispose(); + engine.render.request('earthquake-refresh'); + }, + records(limit) { + return !destroyed && visible + ? rows.slice(0, limit).map((row) => ({ ...row })) + : []; + }, + destroy() { + if (destroyed) return; + destroyed = true; + visible = false; + rows = []; + handle?.delete(); + owner.state.dispose(); + engine.render.request('earthquake-destroy'); + }, + }; +} diff --git a/src/layers/earthquakes/render/navara.test.mjs b/src/layers/earthquakes/render/navara.test.mjs new file mode 100644 index 000000000..32841f4d6 --- /dev/null +++ b/src/layers/earthquakes/render/navara.test.mjs @@ -0,0 +1,148 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../../geo/index.js'; +import { PerspectiveCamera } from 'three'; +import { constantRgba } from '../../shared/color.js'; +import { createEarthquakesNavaraRenderer } from './navara.js'; + +test('Navara earthquake effect is lazy, releases replaced textures and detaches on repeated destroy', () => { + const registered = [], + effects = [], + requests = []; + const view = { + camera: {}, + registerEffect(key, desc) { + registered.push([key, desc]); + }, + addEffect(config) { + const h = { + config, + updates: [], + deleted: 0, + update(value) { + this.updates.push(value); + }, + delete() { + this.deleted++; + }, + }; + effects.push(h); + return h; + }, + }; + const engine = { + render: { + request(reason) { + requests.push(reason); + }, + }, + }; + const renderer = createEarthquakesNavaraRenderer(engine, { + view, + EffectDesc: class {}, + NavaraEffect: class {}, + }); + assert.equal(effects.length, 0); + renderer.setVisible(true); + assert.equal(effects.length, 0); + const disc = { + position: geo.cartesian3.fromDegrees(30, 20), + baseRadius: 32000, + color: constantRgba(1, 0, 0), + fillAlpha: 0.4, + usgsId: 'us-fixture', + mag: 5, + place: 'Fixture', + time: 1000, + depthKm: 8, + }; + renderer.replace([disc]); + assert.equal(effects.length, 1); + const owner = effects[0].config.gevEarthquakeGround.owner; + const old = owner.state; + let disposed = 0; + for (const t of [ + old.edges.texture, + old.packed.texture, + old.gridTexture, + old.candidates.texture, + ]) + t.addEventListener('dispose', () => disposed++); + const record = renderer.records(1)[0]; + assert.equal(record.id, 'us-fixture'); + record.id = 'mutated'; + assert.equal(renderer.records(1)[0].id, 'us-fixture'); + renderer.replace([disc]); + assert.equal(disposed, 4); + assert.notEqual(owner.state, old); + renderer.setVisible(false); + assert.deepEqual(renderer.records(1), []); + assert.deepEqual(effects[0].updates.at(-1), { visible: false }); + renderer.destroy(); + renderer.destroy(); + assert.equal(effects[0].deleted, 1); + renderer.replace([disc]); + assert.equal(effects.length, 1); + const second = createEarthquakesNavaraRenderer(engine, { + view, + EffectDesc: class {}, + NavaraEffect: class {}, + }); + second.destroy(); + assert.equal(registered.length, 1); +}); + +test('static screen data uploads once and invalidates for camera, projection and viewport changes', () => { + let owner; + const view = { + camera: {}, + registerEffect() {}, + addEffect(config) { + owner = config.gevEarthquakeGround.owner; + return { update() {}, delete() {} }; + }, + }; + const renderer = createEarthquakesNavaraRenderer( + { render: { request() {} } }, + { view, EffectDesc: class {}, NavaraEffect: class {} }, + ); + renderer.setVisible(true); + const position = geo.cartesian3.fromDegrees(30, 20); + renderer.replace([ + { + position, + baseRadius: 32000, + color: constantRgba(1, 0, 0), + fillAlpha: 0.4, + }, + ]); + const camera = new PerspectiveCamera(60, 1.6, 1, 30000000); + camera.position.set(position.x * 1.1, position.y * 1.1, position.z * 1.1); + camera.lookAt(position.x, position.y, position.z); + camera.updateMatrixWorld(true); + const viewport = { width: 1280, height: 800 }; + const context = { getInputBuffer: () => viewport }; + const version = () => owner.state.packed.texture.version; + owner.state.update(camera, context); + let previous = version(); + assert.ok( + owner.state.candidates.width > 1, + 'positive visible feature occupies grid cells', + ); + for (let i = 0; i < 100; i++) owner.state.update(camera, context); + assert.equal(version(), previous, 'unchanged frames do not upload geometry'); + camera.position.x += 10; + camera.updateMatrixWorld(true); + owner.state.update(camera, context); + assert.equal(version(), ++previous); + camera.fov = 45; + camera.updateProjectionMatrix(); + owner.state.update(camera, context); + assert.equal(version(), ++previous); + viewport.width = 1600; + owner.state.update(camera, context); + assert.equal(version(), ++previous); + owner.state.update(camera, context); + assert.equal(version(), previous); + renderer.destroy(); +}); diff --git a/src/layers/earthquakes/render/navaraGeometry.js b/src/layers/earthquakes/render/navaraGeometry.js new file mode 100644 index 000000000..299a92565 --- /dev/null +++ b/src/layers/earthquakes/render/navaraGeometry.js @@ -0,0 +1,142 @@ +// Native default circles have 48 boundary samples (1-degree granularity, +// multiplied by eight by EllipseGeometryLibrary). Reproduce the boundary +// mathematically, then classify the visible surface inside its convex sides. +import * as geo from '../../../geo/index.js'; + +export const DISC_EDGES = 48; +const ellipsoid = geo.ellipsoid.WGS84; +export function discFrame(disc) { + const center = { ...disc.position }; + const magnitude = Math.hypot(center.x, center.y, center.z); + const unit = geo.cartesian3.normalize(center, geo.cartesian3.create()); + const east = geo.cartesian3.create(-center.y, center.x, 0); + if (geo.cartesian3.magnitude(east) === 0) east.x = 1; + geo.cartesian3.normalize(east, east); + const sphericalNorth = geo.cartesian3.cross( + unit, + east, + geo.cartesian3.create(), + ); + const up = ellipsoid.geodeticSurfaceNormal(center, geo.cartesian3.create()); + const north = geo.cartesian3.cross(up, east, geo.cartesian3.create()); + const angle = disc.baseRadius / magnitude; + const cosine = Math.cos(angle), + sine = Math.sin(angle); + const boundary = []; + for (let i = 0; i < DISC_EDGES; i++) { + const theta = (i * 2 * Math.PI) / DISC_EDGES; + const a = Math.sin(theta) * sine * magnitude; + const b = -Math.cos(theta) * sine * magnitude; + const p = geo.cartesian3.create( + center.x * cosine + east.x * a + sphericalNorth.x * b, + center.y * cosine + east.y * a + sphericalNorth.y * b, + center.z * cosine + east.z * a + sphericalNorth.z * b, + ); + ellipsoid.scaleToGeodeticSurface(p, p); + const dx = p.x - center.x, + dy = p.y - center.y, + dz = p.z - center.z; + boundary.push({ + x: dx * east.x + dy * east.y + dz * east.z, + y: dx * north.x + dy * north.y + dz * north.z, + world: p, + }); + } + const phase = Math.atan2(boundary[0].x, -boundary[0].y); + const edges = new Float32Array(DISC_EDGES * 4); + for (let i = 0; i < DISC_EDGES; i++) { + const a = boundary[i], + b = boundary[(i + 1) % DISC_EDGES]; + const length = Math.hypot(b.x - a.x, b.y - a.y); + let nx = (b.y - a.y) / length, + ny = -(b.x - a.x) / length; + let offset = nx * a.x + ny * a.y; + if (offset < 0) { + nx = -nx; + ny = -ny; + offset = -offset; + } + let angle = i === 0 ? 0 : Math.atan2(a.x, -a.y) - phase; + if (angle < 0) angle += 2 * Math.PI; + edges.set([nx, ny, offset, angle], i * 4); + } + const r = disc.baseRadius; + return { + center, + phase, + east, + north, + up, + edges, + boundary, + radius: r, + // At orbital LOD the rendered globe mesh can lie well below the + // ellipsoid (measured -26km over Alaska). Native GroundPrimitive uses + // -100km as its conservative default floor; do not clip that surface. + floor: -100000 - ((r * r) / (2 * ellipsoid.minimumRadius)) * 1.1, + ceiling: 12000, + }; +} + +/** Camera-relative transforms in JS float64; only nearby metres enter GLSL. */ +export function viewFrame(frame, matrix, out) { + const m = matrix.elements; + const transform = (p, offset, translate) => { + out[offset] = + m[0] * p.x + m[4] * p.y + m[8] * p.z + (translate ? m[12] : 0); + out[offset + 1] = + m[1] * p.x + m[5] * p.y + m[9] * p.z + (translate ? m[13] : 0); + out[offset + 2] = + m[2] * p.x + m[6] * p.y + m[10] * p.z + (translate ? m[14] : 0); + }; + transform(frame.center, 0, true); + transform(frame.east, 3, false); + transform(frame.north, 6, false); + transform(frame.up, 9, false); + return out; +} + +/** Conservative clip bounds, including a camera inside/crossing the volume. */ +export function discClipBounds(frame, view, projection, out) { + const p = projection.elements; + let x0 = 1, + y0 = 1, + x1 = -1, + y1 = -1, + front = 0, + behind = 0; + for (let i = 0; i < 8; i++) { + const x = (i & 1 ? 1 : -1) * frame.radius; + const y = (i & 2 ? 1 : -1) * frame.radius; + const z = i & 4 ? frame.ceiling : frame.floor; + const vx = view[0] + view[3] * x + view[6] * y + view[9] * z; + const vy = view[1] + view[4] * x + view[7] * y + view[10] * z; + const vz = view[2] + view[5] * x + view[8] * y + view[11] * z; + const w = p[3] * vx + p[7] * vy + p[11] * vz + p[15]; + if (w <= 0) { + behind++; + continue; + } + front++; + const cx = (p[0] * vx + p[4] * vy + p[8] * vz + p[12]) / w; + const cy = (p[1] * vx + p[5] * vy + p[9] * vz + p[13]) / w; + x0 = Math.min(x0, cx); + y0 = Math.min(y0, cy); + x1 = Math.max(x1, cx); + y1 = Math.max(y1, cy); + } + if (front && behind) { + x0 = -1; + y0 = -1; + x1 = 1; + y1 = 1; + } + if (!front || x1 < -1 || y1 < -1 || x0 > 1 || y0 > 1) out.fill(2); + else { + out[0] = Math.max(-1, x0); + out[1] = Math.max(-1, y0); + out[2] = Math.min(1, x1); + out[3] = Math.min(1, y1); + } + return out; +} diff --git a/src/layers/earthquakes/render/navaraGeometry.test.mjs b/src/layers/earthquakes/render/navaraGeometry.test.mjs new file mode 100644 index 000000000..032ca2c58 --- /dev/null +++ b/src/layers/earthquakes/render/navaraGeometry.test.mjs @@ -0,0 +1,237 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { PerspectiveCamera, Vector3 } from 'three'; +import * as geo from '../../../geo/index.js'; +import { + DISC_EDGES, + discFrame, + viewFrame, + discClipBounds, +} from './navaraGeometry.js'; + +test('Navara classifier boundary matches public native ground-circle geometry around the world', () => { + for (const [lon, lat] of [ + [-122.42, 37.77], + [0, 0], + [179.95, 85], + [-179.95, -85], + [23, 90], + ]) { + for (const mag of [2.5, 5, 7, 9, 10]) { + const position = geo.cartesian3.fromDegrees(lon, lat); + const radius = 2 ** mag * 1000; + const frame = discFrame({ position, baseRadius: radius }); + const native = Cesium.EllipseOutlineGeometry.createGeometry( + new Cesium.EllipseOutlineGeometry({ + center: Cesium.Cartesian3.fromDegrees(lon, lat), + semiMajorAxis: radius, + semiMinorAxis: radius, + }), + ).attributes.position.values; + assert.equal(native.length, DISC_EDGES * 3); + for (const p of frame.boundary) { + let best = Infinity; + for (let i = 0; i < native.length; i += 3) + best = Math.min( + best, + Math.hypot( + p.world.x - native[i], + p.world.y - native[i + 1], + p.world.z - native[i + 2], + ), + ); + assert.ok(best < 1e-7, `boundary ${lon}/${lat}/M${mag}: ${best}m`); + for (let i = 0; i < DISC_EDGES; i++) + assert.ok(frame.edges[i * 4 + 2] > 0); + } + } + } +}); + +test('disc screen bounds contain every volume corner and cover a camera crossing the volume', () => { + const position = geo.cartesian3.fromDegrees(-122.42, 37.77); + const frame = discFrame({ position, baseRadius: 32000 }); + const camera = new PerspectiveCamera(60, 1.6, 1, 30000000); + camera.position.copy( + new Vector3(position.x, position.y, position.z).multiplyScalar(1.1), + ); + camera.lookAt(position.x, position.y, position.z); + camera.updateMatrixWorld(true); + const view = viewFrame( + frame, + camera.matrixWorldInverse, + new Float64Array(12), + ); + const bounds = discClipBounds( + frame, + view, + camera.projectionMatrix, + new Float64Array(4), + ); + assert.ok(bounds[0] < bounds[2] && bounds[1] < bounds[3]); + for (let i = 0; i < 8; i++) { + const p = new Vector3(position.x, position.y, position.z); + p.addScaledVector( + new Vector3(frame.east.x, frame.east.y, frame.east.z), + (i & 1 ? 1 : -1) * frame.radius, + ); + p.addScaledVector( + new Vector3(frame.north.x, frame.north.y, frame.north.z), + (i & 2 ? 1 : -1) * frame.radius, + ); + p.addScaledVector( + new Vector3(frame.up.x, frame.up.y, frame.up.z), + i & 4 ? frame.ceiling : frame.floor, + ); + p.project(camera); + assert.ok( + p.x >= bounds[0] - 1e-12 && + p.x <= bounds[2] + 1e-12 && + p.y >= bounds[1] - 1e-12 && + p.y <= bounds[3] + 1e-12, + ); + } + const inside = new Float64Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]); + assert.deepEqual( + [ + ...discClipBounds( + frame, + inside, + camera.projectionMatrix, + new Float64Array(4), + ), + ], + [-1, -1, 1, 1], + ); + inside[2] = 1000000; + assert.deepEqual( + [ + ...discClipBounds( + frame, + inside, + camera.projectionMatrix, + new Float64Array(4), + ), + ], + [2, 2, 2, 2], + ); +}); + +test('classification volume includes the recorded far-LOD Alaska surface and excludes the far side of Earth', () => { + const position = geo.cartesian3.fromDegrees(-149.92, 61); + const frame = discFrame({ position, baseRadius: 2 ** 5.5 * 1000 }); + const surface = { + x: -2674232.841211094, + y: -1508471.7568704002, + z: 5542920.923233327, + }; + const height = + (surface.x - position.x) * frame.up.x + + (surface.y - position.y) * frame.up.y + + (surface.z - position.z) * frame.up.z; + assert.ok( + height < -20000, + 'recorded public picker sample is below the previous floor', + ); + assert.ok(height >= frame.floor && height <= frame.ceiling); + const farSideHeight = + -2 * + (position.x * frame.up.x + + position.y * frame.up.y + + position.z * frame.up.z); + assert.ok( + farSideHeight < frame.floor, + 'opposite hemisphere remains outside the volume', + ); +}); + +test('large-disc sector search preserves full 48-plane pixel coverage, including corners', () => { + const coverage = (frame, point, dx, dy, indices) => { + let result = 1; + for (const i of indices) { + const e = frame.edges.subarray(i * 4, i * 4 + 3); + const width = + Math.abs(e[0] * dx[0] + e[1] * dx[1]) + + Math.abs(e[0] * dy[0] + e[1] * dy[1]); + const distance = e[2] - e[0] * point[0] - e[1] * point[1]; + result = Math.min( + result, + Math.max(0, Math.min(1, distance / Math.max(width, 0.001) + 0.5)), + ); + } + return result; + }; + const all = Array.from({ length: DISC_EDGES }, (_, i) => i); + for (const [lon, lat] of [ + [-122.42, 37.77], + [0, 0], + [179.95, 85], + [-179.95, -85], + [23, 90], + ]) { + for (const mag of [2.5, 5, 7, 9, 10]) { + const frame = discFrame({ + position: geo.cartesian3.fromDegrees(lon, lat), + baseRadius: 2 ** mag * 1000, + }); + const angles = [ + ...frame.boundary.map((p) => Math.atan2(p.x, -p.y)), + ...Array.from({ length: 128 }, (_, i) => (i * Math.PI * 2) / 128), + ]; + for (let i = 1; i < DISC_EDGES; i++) + assert.ok(frame.edges[i * 4 + 3] > frame.edges[(i - 1) * 4 + 3]); + for (const angle of angles) { + const direction = [Math.sin(angle), -Math.cos(angle)]; + let boundary = Infinity; + for (const i of all) { + const dot = + frame.edges[i * 4] * direction[0] + + frame.edges[i * 4 + 1] * direction[1]; + if (dot > 0) + boundary = Math.min(boundary, frame.edges[i * 4 + 2] / dot); + } + const theta = + (((angle - frame.phase) % (2 * Math.PI)) + 2 * Math.PI) % + (2 * Math.PI); + let low = 0, + high = DISC_EDGES - 1; + for (let step = 0; step < 6; step++) { + const middle = Math.floor((low + high + 1) / 2); + if (theta >= frame.edges[middle * 4 + 3]) low = middle; + else high = middle - 1; + } + const adjacent = [(low + 47) % 48, low, (low + 1) % 48]; + for (const divisor of [128.01, 512]) { + const pixel = frame.radius / divisor; + for (const offset of [-1.2, -0.4, 0, 0.4, 1.2]) { + const point = direction.map( + (value) => value * (boundary + offset * pixel), + ); + for (const [dx, dy] of [ + [ + [pixel, 0], + [0, pixel], + ], + [ + [pixel * 0.7, pixel * 0.7], + [-pixel * 0.7, pixel * 0.7], + ], + [ + [pixel, 0], + [pixel * 0.2, pixel * 0.1], + ], + ]) { + const expected = coverage(frame, point, dx, dy, all); + const actual = coverage(frame, point, dx, dy, adjacent); + assert.ok( + Math.abs(actual - expected) < 1e-7, + `coverage ${lon}/${lat}/M${mag}: ${actual} != ${expected}`, + ); + } + } + } + } + } + } +}); diff --git a/src/layers/earthquakes/render/navaraGrid.js b/src/layers/earthquakes/render/navaraGrid.js new file mode 100644 index 000000000..e71965d5a --- /dev/null +++ b/src/layers/earthquakes/render/navaraGrid.js @@ -0,0 +1,69 @@ +// A screen grid of conservative projected volumes. Prefix-sum storage has +// no feature/cell cap: overlapping discs retain their source order. +export const GRID_COLS = 32; +export const GRID_ROWS = 20; +export function createDiscGrid(count) { + const counts = new Uint32Array(GRID_COLS * GRID_ROWS); + const offsets = new Uint32Array(counts.length); + const cursor = new Uint32Array(counts.length); + const bounds = new Float64Array(count * 4); + const ranges = new Int32Array(count * 4); + let indices = new Uint32Array(0); + const clamp = (value, max) => Math.min(max - 1, Math.max(0, value)); + return { + bounds, + counts, + offsets, + get indices() { + return indices; + }, + pack() { + counts.fill(0); + for (let i = 0; i < count; i++) { + const b = i * 4; + const valid = + bounds[b] <= bounds[b + 2] && + bounds[b + 1] <= bounds[b + 3] && + bounds[b] <= 1 && + bounds[b + 1] <= 1 && + bounds[b + 2] >= -1 && + bounds[b + 3] >= -1; + ranges[b] = valid + ? clamp(Math.floor((bounds[b] * 0.5 + 0.5) * GRID_COLS), GRID_COLS) + : -1; + ranges[b + 1] = clamp( + Math.floor((bounds[b + 1] * 0.5 + 0.5) * GRID_ROWS), + GRID_ROWS, + ); + ranges[b + 2] = clamp( + Math.floor((bounds[b + 2] * 0.5 + 0.5) * GRID_COLS), + GRID_COLS, + ); + ranges[b + 3] = clamp( + Math.floor((bounds[b + 3] * 0.5 + 0.5) * GRID_ROWS), + GRID_ROWS, + ); + if (!valid) continue; + for (let y = ranges[b + 1]; y <= ranges[b + 3]; y++) + for (let x = ranges[b]; x <= ranges[b + 2]; x++) + counts[y * GRID_COLS + x]++; + } + let total = 0; + for (let i = 0; i < counts.length; i++) { + offsets[i] = total; + total += counts[i]; + cursor[i] = offsets[i]; + } + if (indices.length < total) + indices = new Uint32Array(Math.max(total, indices.length * 2)); + for (let i = 0; i < count; i++) { + const b = i * 4; + if (ranges[b] < 0) continue; + for (let y = ranges[b + 1]; y <= ranges[b + 3]; y++) + for (let x = ranges[b]; x <= ranges[b + 2]; x++) + indices[cursor[y * GRID_COLS + x]++] = i; + } + return total; + }, + }; +} diff --git a/src/layers/earthquakes/render/navaraGrid.test.mjs b/src/layers/earthquakes/render/navaraGrid.test.mjs new file mode 100644 index 000000000..b9c240541 --- /dev/null +++ b/src/layers/earthquakes/render/navaraGrid.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createDiscGrid, GRID_COLS, GRID_ROWS } from './navaraGrid.js'; + +test('screen grid retains every overlapping feature in source order without a cell cap', () => { + const grid = createDiscGrid(513); + for (let i = 0; i < 513; i++) grid.bounds.set([-1, -1, 1, 1], i * 4); + assert.equal(grid.pack(), 513 * GRID_COLS * GRID_ROWS); + for (let i = 0; i < grid.counts.length; i++) { + assert.equal(grid.counts[i], 513); + assert.deepEqual( + [...grid.indices.subarray(grid.offsets[i], grid.offsets[i] + 513)], + Array.from({ length: 513 }, (_, i) => i), + ); + } + grid.bounds.fill(2); + assert.equal(grid.pack(), 0); + assert.equal( + grid.counts.reduce((a, b) => a + b, 0), + 0, + ); +}); + +test('screen grid excludes off-screen/behind volumes and conservatively includes border cells', () => { + const grid = createDiscGrid(4); + grid.bounds.set([ + -1, -1, -0.9, -0.9, 2, 2, 2, 2, -2, -2, -1.1, -1.1, 0, 0, 1, 1, + ]); + grid.pack(); + assert.equal(grid.counts[0], 1); + assert.equal(grid.indices[grid.offsets[0]], 0); + const last = grid.counts.length - 1; + assert.equal(grid.counts[last], 1); + assert.equal(grid.indices[grid.offsets[last]], 3); + for (let i = 0; i < grid.counts.length; i++) + for (let k = 0; k < grid.counts[i]; k++) + assert.ok([0, 3].includes(grid.indices[grid.offsets[i] + k])); +});