mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
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:
1 parent
c89185f352
commit
a3152ab719
14 files changed
+1106
-16
No files matched your search
@@ -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],
|
||||
],
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
+25
-8
@@ -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,
|
||||
|
||||
@@ -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}`),
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]));
|
||||
});
|
||||
Reference in new issue
Block a user