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.
This commit is contained in:
German Khokhlov committed 2026-10-27 13:42:07 +04:00
1 parent c89185f352
commit a3152ab719
14 files changed
+1106 -16

No files matched your search

+36
View File
@@ -45,3 +45,39 @@ export function earthquakeFixture() {
features, 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],
],
},
})),
};
}
+11 -1
View File
@@ -1184,5 +1184,15 @@
"scripts/fixtures/w2/earthquakes.mjs", "scripts/fixtures/w2/earthquakes.mjs",
"src/tooling/w2QaComparison.test.mjs", "src/tooling/w2QaComparison.test.mjs",
"src/layers/earthquakes/render/cesium.js", "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"
] ]
+4 -1
View File
@@ -2695,7 +2695,10 @@
"src/celestialRing/render/cesium.js", "src/celestialRing/render/cesium.js",
"src/celestialRing/view.js", "src/celestialRing/view.js",
"src/layers/satellites/render/cesium.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": [ "external": [
"hls.js", "hls.js",
+2
View File
@@ -24,6 +24,7 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers,
'rocket-launches', 'rocket-launches',
'satellites', 'satellites',
'ais-live-vessels', 'ais-live-vessels',
'earthquakes',
]) ])
assert.equal(layerAvailableOn(id, 'navara'), true, id); assert.equal(layerAvailableOn(id, 'navara'), true, id);
assert.deepEqual( assert.deepEqual(
@@ -34,6 +35,7 @@ test('the Navara mode registers the aircraft layers’ and vessels’ renderers,
'flights', 'flights',
'military', 'military',
'local-adsb', 'local-adsb',
'earthquakes',
'satellites', 'satellites',
'rocket-launches', 'rocket-launches',
'ais-live-vessels', 'ais-live-vessels',
+25 -8
View File
@@ -41,15 +41,18 @@ export function navaraModeNotice(availability) {
}); });
} }
/** /** Static attribution for the shell and the layers available on Navara. */
* Data credits for what the shell uses without data layers: place search, const NAVARA_CREDIT_KEYS = new Set([
* 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([
'photon-geocoder', 'photon-geocoder',
'openstreetmap', 'openstreetmap',
'reearth-terrain', '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() { export async function registerNavaraLayerRenderers() {
const loaders = { 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 () => { 'rocket-launches': async () => {
const { createLaunchesNavaraRenderer } = const { createLaunchesNavaraRenderer } =
await import('../layers/launches/render/navara.js'); await import('../layers/launches/render/navara.js');
@@ -124,7 +136,12 @@ export async function createNavaraScene({
}); });
defer(() => globe.destroy()); defer(() => globe.destroy());
const { engine } = globe; 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)); defer(() => navaraViews.delete(engine));
// The layers drawn on Navara (their render/navara.js), before the catalog // The layers drawn on Navara (their render/navara.js), before the catalog
// asks the registry which layers this engine shows. // asks the registry which layers this engine shows.
@@ -155,7 +172,7 @@ export async function createNavaraScene({
// Navara's attribution card. // Navara's attribution card.
registerDataCredits( registerDataCredits(
engine, engine,
(credits ?? DATA_CREDITS).filter(({ key }) => SHELL_CREDIT_KEYS.has(key)), (credits ?? DATA_CREDITS).filter(({ key }) => NAVARA_CREDIT_KEYS.has(key)),
); );
return { return {
viewer: null, viewer: null,
+26 -3
View File
@@ -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.equal(run.loads[0].search, '?engine=navara&look=photoreal');
assert.ok(run.scene.operations.searchAndFlyTo); assert.ok(run.scene.operations.searchAndFlyTo);
assert.equal(globalThis.window.__GOOGLE_MAPS_API_KEY__, 'google-test'); assert.equal(globalThis.window.__GOOGLE_MAPS_API_KEY__, 'google-test');
// Data layers are off: only the credits of what the shell itself uses // Match the native static attribution policy for available data layers,
// (place search, its OSM data, keyless terrain) go to the engine's credits. // even before a layer is enabled.
assert.deepEqual( assert.deepEqual(
run.engine.fake.credits.map(({ html }) => html), 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(isRenderGovernorInstalled(run.engine), false);
assert.equal(view.__GOOGLE_MAPS_API_KEY__, undefined); 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}`),
);
});
+7 -1
View File
@@ -2,7 +2,11 @@
// the product way (viewOptions.js) and the GlobeEngine on it. Load it with a // 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. // 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 * as geo from '../../geo/index.js';
import { DefaultPlugin } from '@navaramap/three-default-plugin'; import { DefaultPlugin } from '@navaramap/three-default-plugin';
import { createNavaraEngine } from './createNavaraEngine.js'; import { createNavaraEngine } from './createNavaraEngine.js';
@@ -187,6 +191,8 @@ export async function createNavaraGlobe({
view, view,
engine, engine,
MeshDesc, MeshDesc,
EffectDesc,
NavaraEffect,
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
destroyed = true; destroyed = true;
+19 -2
View File
@@ -901,10 +901,27 @@ export function createContractSuite(
), ),
`a pickable: false sprite was picked (${JSON.stringify(onQuiet.picked?.id)})`, `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( ensure(
onLine.picked?.id === 'line-1', 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 target = screen(a);
const movesBefore = moves.length; const movesBefore = moves.length;
+342
View File
@@ -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(depth<surface-2e-7) return;
vec3 color=toDisplay(inputColor.rgb);
for(int k=0;k<int(range.y);k++) {
float id=atSlot(tCandidates,uCandidateSize,range.x+float(k)).x;
vec4 center=atSlot(tFrames,uFrameSize,id*5.0);
vec4 east=atSlot(tFrames,uFrameSize,id*5.0+1.0);
vec4 north=atSlot(tFrames,uFrameSize,id*5.0+2.0);
vec4 up=atSlot(tFrames,uFrameSize,id*5.0+3.0);
vec3 delta=point-center.xyz;
vec3 local=vec3(dot(delta,east.xyz),dot(delta,north.xyz),dot(delta,up.xyz));
vec2 localDx=vec2(dot(pointDx,east.xyz),dot(pointDx,north.xyz));
vec2 localDy=vec2(dot(pointDy,east.xyz),dot(pointDy,north.xyz));
if(local.z<east.w || local.z>north.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<count;k++) {
int i=(first+k+${DISC_EDGES})%${DISC_EDGES};
vec3 edge=atSlot(tEdges,uEdgeSize,id*${DISC_EDGES}.0+float(i)).xyz;
float d=edge.z-dot(edge.xy,local.xy);
float pixel=abs(dot(edge.xy,localDx))+abs(dot(edge.xy,localDy));
coverage=min(coverage,clamp(d/max(pixel,0.001)+0.5,0.0,1.0));
if(coverage<=0.0) break;
}
if(coverage<=0.0) continue;
vec4 fill=atSlot(tFrames,uFrameSize,id*5.0+4.0);
color=mix(color,fill.rgb,fill.a*coverage);
}
outputColor=vec4(toLinear(color),inputColor.a);
}
`;
function texture(data, width, height) {
const t = new DataTexture(data, width, height, RGBAFormat, FloatType);
t.minFilter = NearestFilter;
t.magFilter = NearestFilter;
t.needsUpdate = true;
return t;
}
function storage(texels) {
const width = Math.min(1024, Math.max(1, texels));
const height = Math.max(1, Math.ceil(texels / width));
const data = new Float32Array(width * height * 4);
return { data, width, height, texture: texture(data, width, height) };
}
function buildState(discs) {
const frames = discs.map(discFrame);
const edges = storage(discs.length * DISC_EDGES);
const packed = storage(discs.length * 5);
const grid = createDiscGrid(discs.length);
const gridData = new Float32Array(GRID_COLS * GRID_ROWS * 4);
const gridTexture = texture(gridData, GRID_COLS, GRID_ROWS);
let candidates = storage(1);
const scratch = new Float64Array(12),
bounds = new Float64Array(4);
const lastView = new Float64Array(16).fill(Number.NaN);
const lastProjection = new Float64Array(16).fill(Number.NaN);
let lastWidth = 0,
lastHeight = 0;
frames.forEach((frame, i) => 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');
},
};
}
@@ -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();
});
@@ -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;
}
@@ -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}`,
);
}
}
}
}
}
}
});
@@ -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;
},
};
}
@@ -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]));
});