mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Keep GNSS history across globe changes and share the Navara ground cell pass
This commit is contained in:
1 parent
6fe5bda028
commit
1930ca247a
20 files changed
+629
-415
No files matched your search
@@ -36,6 +36,7 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
|
||||
const RENDERED_LAYER_DIRS = [
|
||||
'src/layers/alpr/',
|
||||
'src/layers/airQuality/',
|
||||
'src/layers/gnss/',
|
||||
'src/layers/radio/',
|
||||
'src/layers/bikeshare/',
|
||||
'src/layers/earthquakes/',
|
||||
|
||||
@@ -3227,6 +3227,7 @@
|
||||
"src/layers/perimeters/inciweb.js",
|
||||
"src/app/layers/gnss.js",
|
||||
"src/layers/gnss/index.js",
|
||||
"src/layers/gnss/render/cesium.js",
|
||||
"src/layers/gnss/source.js",
|
||||
"src/layers/gnss/records.js",
|
||||
"src/app/layers/ghostMaps.js",
|
||||
@@ -3657,6 +3658,7 @@
|
||||
"src/layers/earthquakes/render/cesium.js",
|
||||
"src/layers/earthquakes/render/navara.js",
|
||||
"src/layers/airQuality/render/navara.js",
|
||||
"src/layers/gnss/render/navara.js",
|
||||
"src/layers/earthquakes/render/navaraGeometry.js",
|
||||
"src/layers/earthquakes/render/navaraGrid.js",
|
||||
"src/layers/installations/render/navara.js",
|
||||
@@ -3683,7 +3685,8 @@
|
||||
"src/layers/alpr/render/navara.js",
|
||||
"src/layers/alpr/render/navaraOverlay.js",
|
||||
"src/layers/radio/screenClusters.js",
|
||||
"src/layers/firms/render/navaraGeometry.js",
|
||||
"src/layers/shared/render/navaraCellGeometry.js",
|
||||
"src/layers/shared/render/navaraGroundCells.js",
|
||||
"src/layers/firms/render/navaraSprites.js",
|
||||
"src/layers/shared/render/navaraGroundGeometry.js",
|
||||
"src/layers/shared/render/navaraGroundGrid.js",
|
||||
@@ -4104,6 +4107,7 @@
|
||||
"src/layers/perimeters/inciweb.js",
|
||||
"src/app/layers/gnss.js",
|
||||
"src/layers/gnss/index.js",
|
||||
"src/layers/gnss/render/cesium.js",
|
||||
"src/layers/gnss/source.js",
|
||||
"src/layers/gnss/records.js",
|
||||
"src/app/layers/ghostMaps.js",
|
||||
@@ -5237,6 +5241,7 @@
|
||||
"exports": ["./layers/gnss"],
|
||||
"modules": [
|
||||
"src/layers/gnss/index.js",
|
||||
"src/layers/gnss/render/cesium.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/layers/gnss/source.js",
|
||||
|
||||
@@ -31,6 +31,10 @@ try {
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = (input, o) => {
|
||||
const url = new URL(input.url ?? input, document.baseURI);
|
||||
if (url.pathname === '/api/gnss-integrity') {
|
||||
return Promise.resolve(Response.json({ageMs: 0, rows: ['a1','a2','a3'].map(hex =>
|
||||
({hex, lat: 43.65, lon: -79.38, nic: 0, nacp: 0, degraded: true, gpsLost: false}))}));
|
||||
}
|
||||
if (url.hostname === 'api.weather.gc.ca') {
|
||||
const records = url.pathname.includes('aqhi-stations')
|
||||
? [
|
||||
@@ -76,12 +80,12 @@ try {
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
};
|
||||
const capture = async (visible) => {
|
||||
await p.evaluate(async (show) => {
|
||||
const capture = async (visible, layerId = 'air-quality') => {
|
||||
await p.evaluate(async (show, id) => {
|
||||
const d = window.__godsEyeView;
|
||||
await d.dataManager.setEnabled('air-quality', show, { origin: 'user' });
|
||||
await d.dataManager.setEnabled(id, show, { origin: 'user' });
|
||||
d.engine.render.request('qa-air-quality');
|
||||
}, visible);
|
||||
}, visible, layerId);
|
||||
await new Promise((r) => setTimeout(r, 3900));
|
||||
const shot = await p.screenshot();
|
||||
return sharp(shot)
|
||||
@@ -97,6 +101,7 @@ try {
|
||||
if (d.tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Keyless required');
|
||||
window.qaAir = d.dataManager.layers.get('air-quality').module;
|
||||
window.qaGnss = d.dataManager.layers.get('gnss-integrity').module;
|
||||
});
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
@@ -105,10 +110,12 @@ try {
|
||||
const same = await p.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('air-quality').module ===
|
||||
window.qaAir,
|
||||
window.qaAir &&
|
||||
window.__godsEyeView.dataManager.layers.get('gnss-integrity').module === window.qaGnss,
|
||||
);
|
||||
assert.ok(same);
|
||||
}
|
||||
await p.evaluate(() => window.__godsEyeView.dataManager.setEnabled('gnss-integrity', false));
|
||||
const off = await capture(false),
|
||||
on = await capture(true);
|
||||
const values = await p.evaluate(() => ({
|
||||
@@ -131,6 +138,14 @@ try {
|
||||
assert.equal(values.records[0].name, 'QA Toronto');
|
||||
assert.equal(values.records[0].value, 9);
|
||||
assert.equal(values.card.length, 1);
|
||||
await capture(false);
|
||||
const cellOff = await capture(false, 'gnss-integrity');
|
||||
const cellOn = await capture(true, 'gnss-integrity');
|
||||
assert.ok(orange(cellOn) > orange(cellOff) + 5, kind + ' must draw GNSS high cell');
|
||||
const gnss = await p.evaluate(() => window.qaGnss.getStats());
|
||||
assert.equal(gnss.levels.high, 1);
|
||||
console.log('PASS', kind, 'GNSS high cell, view anchor', gnss.coverage.latitude, gnss.coverage.longitude);
|
||||
await p.evaluate(() => window.__godsEyeView.dataManager.setEnabled('air-quality', true));
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
@@ -196,10 +196,10 @@ export function createApplicationCatalog({
|
||||
[
|
||||
'gnss-integrity',
|
||||
() =>
|
||||
createApplicationGnssIntegrity({
|
||||
sessionModel('gnss-integrity', () => createApplicationGnssIntegrity({
|
||||
source: sources['gnss-integrity'],
|
||||
windowMs: sources['gnss-integrity'].windowMs,
|
||||
}),
|
||||
})),
|
||||
],
|
||||
[
|
||||
's2-ghostmaps',
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { rendererFor } from './renderers.js';
|
||||
import { createGnssCesiumRenderer } from '../../layers/gnss/render/cesium.js';
|
||||
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
|
||||
|
||||
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
|
||||
export function createApplicationGnssIntegrity(options) {
|
||||
return createGnssIntegrityLayer(options);
|
||||
return createGnssIntegrityLayer({
|
||||
createRenderer: ({ viewer, engine, viewAnchor }) => engine
|
||||
? rendererFor('gnss-integrity', engine, { viewer, viewAnchor })
|
||||
: createGnssCesiumRenderer({ viewer, viewAnchor }),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createGnssCesiumRenderer } from '../../layers/gnss/render/cesium.js';
|
||||
import { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/cesium.js';
|
||||
import { createFirmsCesiumRenderer } from '../../layers/firms/render/cesium.js';
|
||||
import { createEarthquakesCesiumRenderer } from '../../layers/earthquakes/render/cesium.js';
|
||||
@@ -94,6 +95,7 @@ export const layerAvailableOn = (layerId, engineKind) =>
|
||||
export const CESIUM_LAYER_RENDERERS = Object.freeze({
|
||||
earthquakes: createEarthquakesCesiumRenderer,
|
||||
'air-quality': (_engine, options) => createAirQualityCesiumRenderer(options),
|
||||
'gnss-integrity': (_engine, options) => createGnssCesiumRenderer(options),
|
||||
'local-firms': createFirmsCesiumRenderer,
|
||||
'rocket-launches': createLaunchesCesiumRenderer,
|
||||
satellites: createSatellitesCesiumRenderer,
|
||||
|
||||
@@ -60,6 +60,7 @@ test('the aircraft, vessels, satellites, launches and earthquakes have extracted
|
||||
assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
'gnss-integrity',
|
||||
'local-firms',
|
||||
'rocket-launches',
|
||||
'satellites',
|
||||
|
||||
@@ -26,6 +26,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
||||
'ais-live-vessels',
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
'gnss-integrity',
|
||||
'local-firms',
|
||||
'military-installations',
|
||||
'bikeshare',
|
||||
@@ -46,6 +47,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
||||
'local-adsb',
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
'gnss-integrity',
|
||||
'alpr-cameras',
|
||||
'satellites',
|
||||
'rocket-launches',
|
||||
|
||||
@@ -123,6 +123,10 @@ export async function registerNavaraLayerRenderers() {
|
||||
...navaraViews.get(engine),
|
||||
});
|
||||
},
|
||||
'gnss-integrity': async () => {
|
||||
const { createGnssNavaraRenderer } = await import('../layers/gnss/render/navara.js');
|
||||
return (engine) => createGnssNavaraRenderer(engine, navaraViews.get(engine));
|
||||
},
|
||||
'air-quality': async () => {
|
||||
const { createAirQualityNavaraRenderer } =
|
||||
await import('../layers/airQuality/render/navara.js');
|
||||
|
||||
@@ -1,291 +1,26 @@
|
||||
// FIRMS terrain/tiles classification and actual engine mover glows.
|
||||
import { Effect, EffectAttribute } from 'postprocessing';
|
||||
import { Matrix4, Uniform } from 'three';
|
||||
import { createEngineLayerView } from '../../shared/render/engineView.js';
|
||||
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
|
||||
import { LOD_LEVELS } from '../policy.js';
|
||||
import { createFirmsSpriteBank } from './navaraSprites.js';
|
||||
import { rectangleFrame } from './navaraGeometry.js';
|
||||
import {
|
||||
viewFrame,
|
||||
groundClipBounds,
|
||||
} from '../../shared/render/navaraGroundGeometry.js';
|
||||
import {
|
||||
createGroundGrid,
|
||||
GRID_COLS,
|
||||
GRID_ROWS,
|
||||
} from '../../shared/render/navaraGroundGrid.js';
|
||||
import {
|
||||
createGroundTexture,
|
||||
createGroundStorage,
|
||||
groundSurfaceUniforms,
|
||||
updateGroundSurface,
|
||||
} from '../../shared/render/navaraGroundSurface.js';
|
||||
const KEY = 'gevFirmsGround';
|
||||
const registeredViews = new WeakSet();
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform sampler2D tAllDepth;
|
||||
uniform sampler2D tSurfaceDepth;
|
||||
uniform sampler2D tCells;
|
||||
uniform sampler2D tGrid;
|
||||
uniform sampler2D tCandidates;
|
||||
uniform vec2 uCellSize;
|
||||
uniform vec2 uCandidateSize;
|
||||
uniform mat4 uInverseProjection;
|
||||
uniform mat4 uInverseView;
|
||||
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(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
|
||||
}
|
||||
vec3 pixelPlane(float distance,vec3 normal,vec3 dx,vec3 dy) {
|
||||
return vec3(distance,dot(normal,dx),dot(normal,dy));
|
||||
}
|
||||
float rectangleCoverage(vec3 west,vec3 east,vec3 south,vec3 north) {
|
||||
vec3 edges[4];
|
||||
edges[0]=west;edges[1]=east;edges[2]=south;edges[3]=north;
|
||||
float coverage=1.0;
|
||||
int partial=0;
|
||||
for(int i=0;i<4;i++) {
|
||||
float width=abs(edges[i].y)+abs(edges[i].z);
|
||||
coverage=min(coverage,clamp(edges[i].x/max(width,0.001)+0.5,0.0,1.0));
|
||||
if(coverage<=0.0) return 0.0;
|
||||
if(edges[i].x<=width*0.5) partial++;
|
||||
}
|
||||
if(partial<2) return coverage;
|
||||
// Intersect the SAME pixel square with all sides. Minimum soft-edge
|
||||
// coverage creates long miter halos at acute/polar rectangle corners.
|
||||
vec2 polygon[8],clipped[8];
|
||||
polygon[0]=vec2(-0.5,-0.5);polygon[1]=vec2(0.5,-0.5);
|
||||
polygon[2]=vec2(0.5,0.5);polygon[3]=vec2(-0.5,0.5);
|
||||
int count=4;
|
||||
for(int side=0;side<4;side++) {
|
||||
int nextCount=0;
|
||||
vec2 previous=polygon[count-1];
|
||||
float before=edges[side].x+dot(edges[side].yz,previous);
|
||||
for(int i=0;i<8;i++) {
|
||||
if(i>=count) break;
|
||||
vec2 current=polygon[i];
|
||||
float after=edges[side].x+dot(edges[side].yz,current);
|
||||
if((before>0.0&&after<0.0)||(before<0.0&&after>0.0))
|
||||
clipped[nextCount++]=mix(previous,current,before/(before-after));
|
||||
if(after>=0.0) clipped[nextCount++]=current;
|
||||
previous=current;before=after;
|
||||
}
|
||||
count=nextCount;
|
||||
if(count<3) return 0.0;
|
||||
for(int i=0;i<8;i++) { if(i>=count) break;polygon[i]=clipped[i]; }
|
||||
}
|
||||
float area=0.0;
|
||||
vec2 previous=polygon[count-1];
|
||||
for(int i=0;i<8;i++) {
|
||||
if(i>=count) break;
|
||||
area+=previous.x*polygon[i].y-previous.y*polygon[i].x;
|
||||
previous=polygon[i];
|
||||
}
|
||||
return clamp(abs(area)*0.5,0.0,1.0);
|
||||
}
|
||||
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);
|
||||
// Evaluate derivatives before divergent candidate branches, as in quakes.
|
||||
vec3 world=(uInverseView*vec4(point,1.0)).xyz;
|
||||
vec3 dx=(uInverseView*vec4(dFdx(point),0.0)).xyz;
|
||||
vec3 dy=(uInverseView*vec4(dFdy(point),0.0)).xyz;
|
||||
// A quad crossing the sky has no surface derivative. Keep exact halfspaces
|
||||
// there; otherwise its far-plane lane spreads cell AA along the globe limb.
|
||||
float onSurface=1.0-step(1.0,depth);
|
||||
float derivativeMask=1.0-step(0.5,abs(dFdx(onSurface))+abs(dFdy(onSurface)));
|
||||
dx*=derivativeMask;
|
||||
dy*=derivativeMask;
|
||||
if(depth>=1.0) return;
|
||||
vec2 tile=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,(tile+0.5)/vec2(${GRID_COLS}.0,${GRID_ROWS}.0)).xy;
|
||||
if(range.y<0.5) return;
|
||||
float surface=texture2D(tSurfaceDepth,uv).r;
|
||||
if(depth<surface-2e-7) return;
|
||||
float rho=length(world.xy);
|
||||
vec2 radial=world.xy/max(rho,0.001);
|
||||
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 longitude=atSlot(tCells,uCellSize,id*4.0);
|
||||
vec3 south=atSlot(tCells,uCellSize,id*4.0+1.0).xyz;
|
||||
vec3 north=atSlot(tCells,uCellSize,id*4.0+2.0).xyz;
|
||||
vec3 westNormal=vec3(-longitude.x,longitude.y,0.0);
|
||||
vec3 eastNormal=vec3(longitude.z,-longitude.w,0.0);
|
||||
float coverage=rectangleCoverage(
|
||||
pixelPlane(dot(westNormal,world),westNormal,dx,dy),
|
||||
pixelPlane(dot(eastNormal,world),eastNormal,dx,dy),
|
||||
pixelPlane(world.z*south.y-rho*south.x+south.z,vec3(-south.x*radial,south.y),dx,dy),
|
||||
pixelPlane(rho*north.x-world.z*north.y-north.z,vec3(north.x*radial,-north.y),dx,dy));
|
||||
if(coverage<=0.0) continue;
|
||||
vec4 fill=atSlot(tCells,uCellSize,id*4.0+3.0);
|
||||
color=mix(color,fill.rgb,fill.a*coverage);
|
||||
}
|
||||
outputColor=vec4(toLinear(color),inputColor.a);
|
||||
}
|
||||
`;
|
||||
function buildState(cells) {
|
||||
const frames = cells.map(rectangleFrame),
|
||||
packed = createGroundStorage(cells.length * 4);
|
||||
const grid = createGroundGrid(cells.length),
|
||||
gridData = new Float32Array(GRID_COLS * GRID_ROWS * 4);
|
||||
const gridTexture = createGroundTexture(gridData, GRID_COLS, GRID_ROWS);
|
||||
let candidates = createGroundStorage(1);
|
||||
const scratch = new Float64Array(12),
|
||||
bounds = new Float64Array(4);
|
||||
const lastView = new Float64Array(16).fill(NaN),
|
||||
lastProjection = new Float64Array(16).fill(NaN);
|
||||
let lastWidth = 0,
|
||||
lastHeight = 0;
|
||||
frames.forEach((frame, index) => {
|
||||
const offset = index * 16,
|
||||
color = cells[index].color;
|
||||
packed.data.set(frame.bounds.longitude, offset);
|
||||
packed.data.set(frame.bounds.south, offset + 4);
|
||||
packed.data.set(frame.bounds.north, offset + 8);
|
||||
packed.data.set(
|
||||
[color.red, color.green, color.blue, color.alpha],
|
||||
offset + 12,
|
||||
);
|
||||
});
|
||||
return {
|
||||
count: cells.length,
|
||||
packed,
|
||||
gridTexture,
|
||||
get candidates() {
|
||||
return candidates;
|
||||
},
|
||||
update(camera, context) {
|
||||
const input = context.getInputBuffer(),
|
||||
view = camera.matrixWorldInverse.elements,
|
||||
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];
|
||||
if (!changed) return;
|
||||
lastView.set(view);
|
||||
lastProjection.set(projection);
|
||||
lastWidth = input.width;
|
||||
lastHeight = input.height;
|
||||
frames.forEach((frame, index) => {
|
||||
viewFrame(frame, camera.matrixWorldInverse, scratch);
|
||||
groundClipBounds(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, index * 4);
|
||||
});
|
||||
const total = grid.pack();
|
||||
if (candidates.data.length / 4 < total) {
|
||||
candidates.texture.dispose();
|
||||
candidates = createGroundStorage(
|
||||
Math.max(total, candidates.data.length / 2),
|
||||
);
|
||||
}
|
||||
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];
|
||||
}
|
||||
gridTexture.needsUpdate = true;
|
||||
candidates.texture.needsUpdate = true;
|
||||
},
|
||||
dispose() {
|
||||
packed.texture.dispose();
|
||||
gridTexture.dispose();
|
||||
candidates.texture.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
function groundEffectClass(EffectDesc, NavaraEffect) {
|
||||
return class FirmsGroundEffectDesc extends EffectDesc {
|
||||
static key = KEY;
|
||||
static insertAfter = [
|
||||
'gevEarthquakeGround',
|
||||
'gevRecentImagery',
|
||||
'toneMapping',
|
||||
];
|
||||
static insertBefore = [
|
||||
'gevPreSharpenOverlay',
|
||||
'gevSharpen',
|
||||
'smaa',
|
||||
'fxaa',
|
||||
'gevOverlay',
|
||||
'final',
|
||||
];
|
||||
constructor(view, context, config) {
|
||||
super(view, context, config);
|
||||
this.owner = config[KEY].owner;
|
||||
}
|
||||
createPass() {
|
||||
this.effect = new Effect('GevFirmsGround', fragmentShader, {
|
||||
attributes: EffectAttribute.CONVOLUTION,
|
||||
uniforms: new Map([
|
||||
...groundSurfaceUniforms(),
|
||||
['tCells', new Uniform(null)],
|
||||
['tGrid', new Uniform(null)],
|
||||
['tCandidates', new Uniform(null)],
|
||||
['uCellSize', new Uniform([1, 1])],
|
||||
['uCandidateSize', new Uniform([1, 1])],
|
||||
['uInverseView', new Uniform(new Matrix4())],
|
||||
]),
|
||||
});
|
||||
return new NavaraEffect(this.view.camera.raw, this.effect);
|
||||
}
|
||||
update() {
|
||||
const state = this.owner.state,
|
||||
camera = this.view.camera.raw;
|
||||
if (!this.effect || !state || !camera) return;
|
||||
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
|
||||
state.update(camera, this.ctx);
|
||||
const u = this.effect.uniforms;
|
||||
u.get('tCells').value = state.packed.texture;
|
||||
u.get('uCellSize').value[0] = state.packed.width;
|
||||
u.get('uCellSize').value[1] = state.packed.height;
|
||||
u.get('tGrid').value = state.gridTexture;
|
||||
u.get('tCandidates').value = state.candidates.texture;
|
||||
u.get('uCandidateSize').value[0] = state.candidates.width;
|
||||
u.get('uCandidateSize').value[1] = state.candidates.height;
|
||||
u.get('uInverseView').value.copy(camera.matrixWorld);
|
||||
}
|
||||
onDestroy() {
|
||||
this.effect?.dispose();
|
||||
super.onDestroy();
|
||||
}
|
||||
};
|
||||
}
|
||||
import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js';
|
||||
export function createFirmsNavaraRenderer(
|
||||
engine,
|
||||
{ view: nativeView, EffectDesc, NavaraEffect, id = 'local-firms' } = {},
|
||||
) {
|
||||
if (!engine?.movers || !nativeView?.camera || !EffectDesc || !NavaraEffect)
|
||||
throw new TypeError('FIRMS requires a Navara view');
|
||||
if (!registeredViews.has(nativeView)) {
|
||||
nativeView.registerEffect(KEY, groundEffectClass(EffectDesc, NavaraEffect));
|
||||
registeredViews.add(nativeView);
|
||||
}
|
||||
const display = createGroundCellDisplay(
|
||||
engine,
|
||||
{ view: nativeView, EffectDesc, NavaraEffect },
|
||||
1,
|
||||
);
|
||||
let destroyed = false,
|
||||
visible = false,
|
||||
cells = [],
|
||||
queued = false,
|
||||
handle = null,
|
||||
sprites = null;
|
||||
const owner = { state: buildState([]) },
|
||||
dataSource = { show: false },
|
||||
const dataSource = { show: false },
|
||||
handlers = new Set();
|
||||
const adapter = createEngineLayerView(engine, {
|
||||
layerId: id,
|
||||
@@ -293,24 +28,13 @@ export function createFirmsNavaraRenderer(
|
||||
});
|
||||
adapter.view.camera.moveEnd = engine.camera.moveEnd;
|
||||
adapter.view.scene.preRender = engine.frames.preRender;
|
||||
const sync = () => {
|
||||
const show = visible && owner.state.count > 0;
|
||||
if (show && !handle)
|
||||
handle = nativeView.addEffect({ [KEY]: { owner }, visible: true });
|
||||
else handle?.update({ visible: show });
|
||||
};
|
||||
const scheduleCells = () => {
|
||||
if (queued || destroyed) return;
|
||||
queued = true;
|
||||
queueMicrotask(() => {
|
||||
queued = false;
|
||||
if (destroyed) return;
|
||||
const next = buildState(cells),
|
||||
previous = owner.state;
|
||||
owner.state = next;
|
||||
sync();
|
||||
previous.dispose();
|
||||
if (visible) engine.render.request('firms-cells');
|
||||
display.replace(cells);
|
||||
});
|
||||
};
|
||||
const removeSprites = () => {
|
||||
@@ -366,7 +90,7 @@ export function createFirmsNavaraRenderer(
|
||||
visible = next;
|
||||
dataSource.show = next;
|
||||
sprites?.setVisible(next);
|
||||
sync();
|
||||
display.setVisible(visible);
|
||||
engine.render.request('firms-visibility');
|
||||
},
|
||||
clearCells() {
|
||||
@@ -406,8 +130,7 @@ export function createFirmsNavaraRenderer(
|
||||
cells = [];
|
||||
for (const handler of [...handlers]) handler.destroy();
|
||||
removeSprites();
|
||||
handle?.delete();
|
||||
owner.state.dispose();
|
||||
display.destroy();
|
||||
adapter.destroy();
|
||||
engine.render.request('firms-destroy');
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createEngineEvent } from '../../../globe/contract.js';
|
||||
@@ -130,6 +131,32 @@ test('ground changes are atomic, lazy and dispose replaced and destroyed GPU tex
|
||||
second.destroy();
|
||||
assert.equal(h.registered.length, 1);
|
||||
});
|
||||
test('GNSS and FIRMS share one ground pass without hiding or destroying one another', async () => {
|
||||
const h = harness();
|
||||
const gnss = createGroundCellDisplay(h.engine, h.options, 0);
|
||||
const fire = h.renderer;
|
||||
try {
|
||||
fire.setVisible(true);
|
||||
fire.addCell(cell);
|
||||
await settle();
|
||||
gnss.replace([cell, cell]);
|
||||
gnss.setVisible(true);
|
||||
const owner = h.effects[0].config.gevFirmsGround.owner;
|
||||
assert.equal(h.effects.length, 1);
|
||||
assert.equal(owner.state.count, 3);
|
||||
gnss.setVisible(false);
|
||||
assert.equal(owner.state.count, 1);
|
||||
gnss.setVisible(true);
|
||||
fire.destroy();
|
||||
assert.equal(owner.state.count, 2);
|
||||
assert.equal(h.effects[0].deleted, 0);
|
||||
} finally {
|
||||
fire.destroy();
|
||||
gnss.destroy();
|
||||
}
|
||||
assert.equal(h.effects[0].deleted, 1);
|
||||
});
|
||||
|
||||
test('FIRMS selection uses actual engine event picks and releases its dispatcher subscriptions', () => {
|
||||
const h = harness(),
|
||||
r = h.renderer;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
rectangleBoundaries,
|
||||
rectangleDistances,
|
||||
rectangleFrame,
|
||||
} from './navaraGeometry.js';
|
||||
} from '../../shared/render/navaraCellGeometry.js';
|
||||
const cases = [];
|
||||
for (const lat of [-90, -80, -45, 0, 45, 80, 88])
|
||||
for (const west of [-180, -123, 0, 178])
|
||||
|
||||
+53
-116
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createGnssCesiumRenderer } from './render/cesium.js';
|
||||
export { defaultGnssViewAnchor } from './render/cesium.js';
|
||||
import { formatNumber, msg, t } from '../../i18n/index.js';
|
||||
import { translateFeedError } from '../../ui/statusText.js';
|
||||
import {
|
||||
@@ -13,17 +14,6 @@ import {
|
||||
export * from './records.js';
|
||||
export { createAdsbGnssSource } from './source.js';
|
||||
|
||||
/**
|
||||
* GroundPrimitive allowPicking:false suppresses ids but still submits stencil
|
||||
* commands in Cesium's pick pass, erasing picks of overlapping ground geometry.
|
||||
* This fill participates only in rendering, so sibling polygons remain pickable.
|
||||
*/
|
||||
class GnssGroundFill extends Cesium.GroundPrimitive {
|
||||
update(frameState) {
|
||||
if (!frameState.passes.pick) super.update(frameState);
|
||||
}
|
||||
}
|
||||
|
||||
/** Low stays visible over land so coverage reads apart from no data. */
|
||||
const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 });
|
||||
const LEVEL_LABELS = Object.freeze({
|
||||
@@ -32,34 +22,6 @@ const LEVEL_LABELS = Object.freeze({
|
||||
high: msg('Low-accuracy score > 10%'),
|
||||
});
|
||||
|
||||
/** Degrees at the centre of the current view, or null before the camera settles. */
|
||||
export function defaultGnssViewAnchor(viewer) {
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
const ellipsoid = viewer?.scene?.globe?.ellipsoid;
|
||||
// At globe scale the visible rectangle can be the whole world, whose
|
||||
// midpoint is unrelated to the place under the screen centre.
|
||||
const centre =
|
||||
canvas?.clientWidth > 0 &&
|
||||
canvas.clientHeight > 0 &&
|
||||
viewer.camera?.pickEllipsoid?.(
|
||||
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
|
||||
ellipsoid,
|
||||
);
|
||||
const rectangle =
|
||||
!centre && viewer?.camera?.computeViewRectangle?.(ellipsoid);
|
||||
const carto = centre
|
||||
? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre)
|
||||
: rectangle
|
||||
? Cesium.Rectangle.center(rectangle)
|
||||
: viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
const latitude = Cesium.Math.toDegrees(carto.latitude);
|
||||
const longitude = Cesium.Math.toDegrees(carto.longitude);
|
||||
return Number.isFinite(latitude) && Number.isFinite(longitude)
|
||||
? { latitude, longitude }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Own one GNSS navigation-integrity display: rolling integrity observations
|
||||
* around the views the user visits, binned into cells coloured by the
|
||||
@@ -67,7 +29,8 @@ export function defaultGnssViewAnchor(viewer) {
|
||||
*/
|
||||
export function createGnssIntegrityLayer({
|
||||
source,
|
||||
viewAnchor = defaultGnssViewAnchor,
|
||||
viewAnchor,
|
||||
createRenderer = createGnssCesiumRenderer,
|
||||
now = () => Date.now(),
|
||||
monotonicNow = () => performance.now(),
|
||||
windowMs: requestedWindowMs,
|
||||
@@ -80,8 +43,9 @@ export function createGnssIntegrityLayer({
|
||||
Number.isFinite(requestedWindowMs) && requestedWindowMs > 0
|
||||
? requestedWindowMs
|
||||
: GNSS_WINDOW_MS;
|
||||
let _viewer = null;
|
||||
let _primitives = null;
|
||||
let initialized = false;
|
||||
let renderer = null;
|
||||
let lastAnchor = null;
|
||||
let _request = null;
|
||||
let _enabled = false;
|
||||
let _cells = [];
|
||||
@@ -109,9 +73,9 @@ export function createGnssIntegrityLayer({
|
||||
: null;
|
||||
|
||||
function scheduleCameraRefresh() {
|
||||
if (!_enabled || !_primitives) return;
|
||||
if (!_enabled || !renderer) return;
|
||||
if (now() < _retryAt) return;
|
||||
const key = queryKey(viewAnchor(_viewer));
|
||||
const key = queryKey(renderer?.anchor());
|
||||
if (!key || key === _lastQueryKey) return;
|
||||
clearTimeout(_cameraTimer);
|
||||
// Debounce drags and bound moving/follow cameras to at most 24 requests
|
||||
@@ -120,7 +84,7 @@ export function createGnssIntegrityLayer({
|
||||
async () => {
|
||||
_cameraTimer = null;
|
||||
if (!_enabled || _backgroundRefresh || _request) return;
|
||||
const key = queryKey(viewAnchor(_viewer));
|
||||
const key = queryKey(renderer?.anchor());
|
||||
if (!key || key === _lastQueryKey) return;
|
||||
await refreshInBackground();
|
||||
},
|
||||
@@ -174,45 +138,8 @@ export function createGnssIntegrityLayer({
|
||||
_cells.map(({ id, level }) => [id, level]),
|
||||
);
|
||||
if (signature === _signature) return;
|
||||
renderer?.replace(_cells, FILL_ALPHA);
|
||||
_signature = signature;
|
||||
_primitives.removeAll();
|
||||
if (_cells.length) {
|
||||
_primitives.add(
|
||||
new GnssGroundFill({
|
||||
geometryInstances: _cells.map(
|
||||
(cell) =>
|
||||
new Cesium.GeometryInstance({
|
||||
id: `${layer.id}:${cell.id}`,
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: Cesium.Rectangle.fromDegrees(
|
||||
cell.west,
|
||||
cell.south,
|
||||
cell.east,
|
||||
cell.north,
|
||||
),
|
||||
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
Cesium.Color.fromCssColorString(
|
||||
GNSS_LEVEL_COLORS[cell.level],
|
||||
).withAlpha(FILL_ALPHA[cell.level]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
),
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
}),
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
allowPicking: false,
|
||||
// Retain the compact rectangle descriptors for inspection, not Entity visualizers.
|
||||
releaseGeometryInstances: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
_viewer?.scene?.requestRender?.();
|
||||
}
|
||||
|
||||
const layer = {
|
||||
@@ -228,29 +155,45 @@ export function createGnssIntegrityLayer({
|
||||
_manager = manager;
|
||||
},
|
||||
|
||||
init(viewer) {
|
||||
if (_viewer)
|
||||
init(viewer, { engine = null } = {}) {
|
||||
if (initialized)
|
||||
throw new Error('GNSS integrity layer is already initialized');
|
||||
_viewer = viewer;
|
||||
_primitives = new Cesium.PrimitiveCollection();
|
||||
_primitives.id = layer.id;
|
||||
_primitives.show = false;
|
||||
viewer.scene.groundPrimitives.add(_primitives);
|
||||
_removeCameraEnd = viewer.camera?.moveEnd?.addEventListener(
|
||||
scheduleCameraRefresh,
|
||||
);
|
||||
_removeCameraChanged = viewer.camera?.changed?.addEventListener(
|
||||
scheduleCameraRefresh,
|
||||
);
|
||||
initialized = true;
|
||||
try {
|
||||
if (viewer || engine) this.attachRenderer(viewer, { engine });
|
||||
} catch (error) {
|
||||
this.destroy();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
attachRenderer(viewer, { engine = null } = {}) {
|
||||
if (!initialized || renderer)
|
||||
throw new Error('Invalid GNSS scene attachment');
|
||||
renderer = createRenderer({ viewer, engine, viewAnchor });
|
||||
_removeCameraEnd = renderer.camera?.moveEnd?.addEventListener(scheduleCameraRefresh);
|
||||
_removeCameraChanged = renderer.camera?.changed?.addEventListener(scheduleCameraRefresh);
|
||||
_signature = null;
|
||||
if (_enabled) expire();
|
||||
renderer.setVisible(_enabled);
|
||||
},
|
||||
|
||||
detachRenderer() {
|
||||
clearTimeout(_cameraTimer);
|
||||
_cameraTimer = null;
|
||||
_removeCameraEnd?.();
|
||||
_removeCameraChanged?.();
|
||||
_removeCameraEnd = null;
|
||||
_removeCameraChanged = null;
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
_signature = null;
|
||||
},
|
||||
|
||||
enable() {
|
||||
_enabled = true;
|
||||
// Cells left from before a long disable must not reappear past the window.
|
||||
if (_primitives) {
|
||||
expire();
|
||||
_primitives.show = true;
|
||||
}
|
||||
expire();
|
||||
renderer?.setVisible(true);
|
||||
},
|
||||
|
||||
disable() {
|
||||
@@ -263,11 +206,11 @@ export function createGnssIntegrityLayer({
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
_enabled = false;
|
||||
if (_primitives) _primitives.show = false;
|
||||
renderer?.setVisible(false);
|
||||
},
|
||||
|
||||
async update(_viewerArg, { signal } = {}) {
|
||||
if (!_enabled || !_primitives || signal?.aborted) return false;
|
||||
if (!_enabled || signal?.aborted) return false;
|
||||
if (now() < _retryAt) {
|
||||
expire();
|
||||
scheduleRetry();
|
||||
@@ -276,7 +219,8 @@ export function createGnssIntegrityLayer({
|
||||
_retryAt = 0;
|
||||
clearTimeout(_retryTimer);
|
||||
_retryTimer = null;
|
||||
const anchor = viewAnchor(_viewer);
|
||||
const anchor = renderer ? renderer.anchor() : lastAnchor;
|
||||
lastAnchor = anchor;
|
||||
// No anchor yet (camera still settling) is not a failure: returning
|
||||
// false would make the manager reject the enable. The next interval
|
||||
// retries; cells already drawn still age out of the window.
|
||||
@@ -352,20 +296,13 @@ export function createGnssIntegrityLayer({
|
||||
}
|
||||
},
|
||||
|
||||
destroy(viewer = _viewer) {
|
||||
destroy() {
|
||||
layer.disable();
|
||||
_removeCameraEnd?.();
|
||||
_removeCameraChanged?.();
|
||||
_removeCameraEnd = null;
|
||||
_removeCameraChanged = null;
|
||||
initialized = false;
|
||||
layer.detachRenderer();
|
||||
_observations.clear();
|
||||
_cells = [];
|
||||
_signature = null;
|
||||
if (_primitives) {
|
||||
viewer.scene.groundPrimitives.remove(_primitives);
|
||||
_primitives = null;
|
||||
}
|
||||
_viewer = null;
|
||||
lastAnchor = null;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_stale = false;
|
||||
|
||||
@@ -698,3 +698,24 @@ test('view anchor falls back to rectangle, then nadir, then no camera', () => {
|
||||
assert.ok(Math.abs(nadirAnchor.latitude - 41) < 1e-10);
|
||||
assert.ok(Math.abs(nadirAnchor.longitude - 21) < 1e-10);
|
||||
});
|
||||
|
||||
test('an in-flight regional response supplies the next renderer without a new request', async () => {
|
||||
let resolve, calls = 0;
|
||||
const h = harness(() => { calls++; return new Promise(done => { resolve = done; }); });
|
||||
try {
|
||||
h.layer.enable();
|
||||
const pending = h.layer.update();
|
||||
h.layer.detachRenderer();
|
||||
assert.equal(h.viewer.camera.changed.numberOfListeners, 0);
|
||||
resolve({ rows: threeHealthy() });
|
||||
assert.equal(await pending, true);
|
||||
assert.equal(h.sources.length, 0);
|
||||
assert.equal(h.layer.getStats().levels.low, 1);
|
||||
h.layer.attachRenderer(h.viewer);
|
||||
assert.equal(h.sources[0].length, 1);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(h.viewer.camera.changed.numberOfListeners, 1);
|
||||
} finally {
|
||||
h.layer.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { GNSS_LEVEL_COLORS } from '../records.js';
|
||||
|
||||
/**
|
||||
* GroundPrimitive allowPicking:false suppresses ids but still submits stencil
|
||||
* commands in Cesium's pick pass, erasing picks of overlapping ground geometry.
|
||||
* This fill participates only in rendering, so sibling polygons remain pickable.
|
||||
*/
|
||||
class GnssGroundFill extends Cesium.GroundPrimitive {
|
||||
update(frameState) {
|
||||
if (!frameState.passes.pick) super.update(frameState);
|
||||
}
|
||||
}
|
||||
|
||||
/** Degrees at the centre of the current view, or null before the camera settles. */
|
||||
export function defaultGnssViewAnchor(viewer) {
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
const ellipsoid = viewer?.scene?.globe?.ellipsoid;
|
||||
// At globe scale the visible rectangle can be the whole world, whose
|
||||
// midpoint is unrelated to the place under the screen centre.
|
||||
const centre =
|
||||
canvas?.clientWidth > 0 &&
|
||||
canvas.clientHeight > 0 &&
|
||||
viewer.camera?.pickEllipsoid?.(
|
||||
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
|
||||
ellipsoid,
|
||||
);
|
||||
const rectangle =
|
||||
!centre && viewer?.camera?.computeViewRectangle?.(ellipsoid);
|
||||
const carto = centre
|
||||
? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre)
|
||||
: rectangle
|
||||
? Cesium.Rectangle.center(rectangle)
|
||||
: viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
const latitude = Cesium.Math.toDegrees(carto.latitude);
|
||||
const longitude = Cesium.Math.toDegrees(carto.longitude);
|
||||
return Number.isFinite(latitude) && Number.isFinite(longitude)
|
||||
? { latitude, longitude }
|
||||
: null;
|
||||
}
|
||||
|
||||
export function createGnssCesiumRenderer({
|
||||
viewer,
|
||||
viewAnchor = defaultGnssViewAnchor,
|
||||
}) {
|
||||
const primitives = new Cesium.PrimitiveCollection();
|
||||
primitives.id = 'gnss-integrity';
|
||||
primitives.show = false;
|
||||
viewer.scene.groundPrimitives.add(primitives);
|
||||
return {
|
||||
camera: viewer.camera,
|
||||
anchor: () => viewAnchor(viewer),
|
||||
setVisible(show) {
|
||||
primitives.show = show;
|
||||
},
|
||||
replace(cells, alpha) {
|
||||
primitives.removeAll();
|
||||
if (cells.length) {
|
||||
primitives.add(
|
||||
new GnssGroundFill({
|
||||
geometryInstances: cells.map(
|
||||
(cell) =>
|
||||
new Cesium.GeometryInstance({
|
||||
id: `gnss-integrity:${cell.id}`,
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: Cesium.Rectangle.fromDegrees(
|
||||
cell.west,
|
||||
cell.south,
|
||||
cell.east,
|
||||
cell.north,
|
||||
),
|
||||
vertexFormat:
|
||||
Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
Cesium.Color.fromCssColorString(
|
||||
GNSS_LEVEL_COLORS[cell.level],
|
||||
).withAlpha(alpha[cell.level]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
),
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
}),
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
allowPicking: false,
|
||||
// Retain the compact rectangle descriptors for inspection, not Entity visualizers.
|
||||
releaseGeometryInstances: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
viewer?.scene?.requestRender?.();
|
||||
},
|
||||
destroy() {
|
||||
viewer.scene.groundPrimitives.remove(primitives);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { GNSS_LEVEL_COLORS } from '../records.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js';
|
||||
|
||||
export function createGnssNavaraRenderer(engine, options) {
|
||||
const cells = createGroundCellDisplay(engine, options, 0);
|
||||
const { camera } = engine;
|
||||
function anchor() {
|
||||
const { width, height } = engine.host.size();
|
||||
const centre =
|
||||
width && height && camera.pickEllipsoid({ x: width / 2, y: height / 2 });
|
||||
let position = centre && geo.cartographic.fromCartesian(centre);
|
||||
if (!position) {
|
||||
const rect = camera.viewRectangle();
|
||||
if (rect) {
|
||||
const east =
|
||||
rect.east < rect.west ? rect.east + 2 * Math.PI : rect.east;
|
||||
const longitude = (rect.west + east) / 2;
|
||||
position = {
|
||||
latitude: (rect.south + rect.north) / 2,
|
||||
longitude: longitude > Math.PI ? longitude - 2 * Math.PI : longitude,
|
||||
};
|
||||
} else position = camera.pose().cartographic;
|
||||
}
|
||||
return {
|
||||
latitude: geo.math.toDegrees(position.latitude),
|
||||
longitude: geo.math.toDegrees(position.longitude),
|
||||
};
|
||||
}
|
||||
return {
|
||||
camera,
|
||||
anchor,
|
||||
setVisible: cells.setVisible,
|
||||
destroy: cells.destroy,
|
||||
replace(rows, alpha) {
|
||||
cells.replace(
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
color: rgbaFromCss(GNSS_LEVEL_COLORS[row.level]).withAlpha(
|
||||
alpha[row.level],
|
||||
),
|
||||
})),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,320 @@
|
||||
// Classify visible terrain and 3D tiles. Multiple cell layers share one non-pickable pass.
|
||||
import { Effect, EffectAttribute } from 'postprocessing';
|
||||
import { Matrix4, Uniform } from 'three';
|
||||
import { rectangleFrame } from './navaraCellGeometry.js';
|
||||
import {
|
||||
viewFrame,
|
||||
groundClipBounds,
|
||||
} from './navaraGroundGeometry.js';
|
||||
import {
|
||||
createGroundGrid,
|
||||
GRID_COLS,
|
||||
GRID_ROWS,
|
||||
} from './navaraGroundGrid.js';
|
||||
import {
|
||||
createGroundTexture,
|
||||
createGroundStorage,
|
||||
groundSurfaceUniforms,
|
||||
updateGroundSurface,
|
||||
} from './navaraGroundSurface.js';
|
||||
const KEY = 'gevFirmsGround';
|
||||
const registeredViews = new WeakSet();
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform sampler2D tAllDepth;
|
||||
uniform sampler2D tSurfaceDepth;
|
||||
uniform sampler2D tCells;
|
||||
uniform sampler2D tGrid;
|
||||
uniform sampler2D tCandidates;
|
||||
uniform vec2 uCellSize;
|
||||
uniform vec2 uCandidateSize;
|
||||
uniform mat4 uInverseProjection;
|
||||
uniform mat4 uInverseView;
|
||||
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(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
|
||||
}
|
||||
vec3 pixelPlane(float distance,vec3 normal,vec3 dx,vec3 dy) {
|
||||
return vec3(distance,dot(normal,dx),dot(normal,dy));
|
||||
}
|
||||
float rectangleCoverage(vec3 west,vec3 east,vec3 south,vec3 north) {
|
||||
vec3 edges[4];
|
||||
edges[0]=west;edges[1]=east;edges[2]=south;edges[3]=north;
|
||||
float coverage=1.0;
|
||||
int partial=0;
|
||||
for(int i=0;i<4;i++) {
|
||||
float width=abs(edges[i].y)+abs(edges[i].z);
|
||||
coverage=min(coverage,clamp(edges[i].x/max(width,0.001)+0.5,0.0,1.0));
|
||||
if(coverage<=0.0) return 0.0;
|
||||
if(edges[i].x<=width*0.5) partial++;
|
||||
}
|
||||
if(partial<2) return coverage;
|
||||
// Intersect the SAME pixel square with all sides. Minimum soft-edge
|
||||
// coverage creates long miter halos at acute/polar rectangle corners.
|
||||
vec2 polygon[8],clipped[8];
|
||||
polygon[0]=vec2(-0.5,-0.5);polygon[1]=vec2(0.5,-0.5);
|
||||
polygon[2]=vec2(0.5,0.5);polygon[3]=vec2(-0.5,0.5);
|
||||
int count=4;
|
||||
for(int side=0;side<4;side++) {
|
||||
int nextCount=0;
|
||||
vec2 previous=polygon[count-1];
|
||||
float before=edges[side].x+dot(edges[side].yz,previous);
|
||||
for(int i=0;i<8;i++) {
|
||||
if(i>=count) break;
|
||||
vec2 current=polygon[i];
|
||||
float after=edges[side].x+dot(edges[side].yz,current);
|
||||
if((before>0.0&&after<0.0)||(before<0.0&&after>0.0))
|
||||
clipped[nextCount++]=mix(previous,current,before/(before-after));
|
||||
if(after>=0.0) clipped[nextCount++]=current;
|
||||
previous=current;before=after;
|
||||
}
|
||||
count=nextCount;
|
||||
if(count<3) return 0.0;
|
||||
for(int i=0;i<8;i++) { if(i>=count) break;polygon[i]=clipped[i]; }
|
||||
}
|
||||
float area=0.0;
|
||||
vec2 previous=polygon[count-1];
|
||||
for(int i=0;i<8;i++) {
|
||||
if(i>=count) break;
|
||||
area+=previous.x*polygon[i].y-previous.y*polygon[i].x;
|
||||
previous=polygon[i];
|
||||
}
|
||||
return clamp(abs(area)*0.5,0.0,1.0);
|
||||
}
|
||||
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);
|
||||
// Evaluate derivatives before divergent candidate branches, as in quakes.
|
||||
vec3 world=(uInverseView*vec4(point,1.0)).xyz;
|
||||
vec3 dx=(uInverseView*vec4(dFdx(point),0.0)).xyz;
|
||||
vec3 dy=(uInverseView*vec4(dFdy(point),0.0)).xyz;
|
||||
// A quad crossing the sky has no surface derivative. Keep exact halfspaces
|
||||
// there; otherwise its far-plane lane spreads cell AA along the globe limb.
|
||||
float onSurface=1.0-step(1.0,depth);
|
||||
float derivativeMask=1.0-step(0.5,abs(dFdx(onSurface))+abs(dFdy(onSurface)));
|
||||
dx*=derivativeMask;
|
||||
dy*=derivativeMask;
|
||||
if(depth>=1.0) return;
|
||||
vec2 tile=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,(tile+0.5)/vec2(${GRID_COLS}.0,${GRID_ROWS}.0)).xy;
|
||||
if(range.y<0.5) return;
|
||||
float surface=texture2D(tSurfaceDepth,uv).r;
|
||||
if(depth<surface-2e-7) return;
|
||||
float rho=length(world.xy);
|
||||
vec2 radial=world.xy/max(rho,0.001);
|
||||
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 longitude=atSlot(tCells,uCellSize,id*4.0);
|
||||
vec3 south=atSlot(tCells,uCellSize,id*4.0+1.0).xyz;
|
||||
vec3 north=atSlot(tCells,uCellSize,id*4.0+2.0).xyz;
|
||||
vec3 westNormal=vec3(-longitude.x,longitude.y,0.0);
|
||||
vec3 eastNormal=vec3(longitude.z,-longitude.w,0.0);
|
||||
float coverage=rectangleCoverage(
|
||||
pixelPlane(dot(westNormal,world),westNormal,dx,dy),
|
||||
pixelPlane(dot(eastNormal,world),eastNormal,dx,dy),
|
||||
pixelPlane(world.z*south.y-rho*south.x+south.z,vec3(-south.x*radial,south.y),dx,dy),
|
||||
pixelPlane(rho*north.x-world.z*north.y-north.z,vec3(north.x*radial,-north.y),dx,dy));
|
||||
if(coverage<=0.0) continue;
|
||||
vec4 fill=atSlot(tCells,uCellSize,id*4.0+3.0);
|
||||
color=mix(color,fill.rgb,fill.a*coverage);
|
||||
}
|
||||
outputColor=vec4(toLinear(color),inputColor.a);
|
||||
}
|
||||
`;
|
||||
function buildState(cells) {
|
||||
const frames = cells.map(rectangleFrame),
|
||||
packed = createGroundStorage(cells.length * 4);
|
||||
const grid = createGroundGrid(cells.length),
|
||||
gridData = new Float32Array(GRID_COLS * GRID_ROWS * 4);
|
||||
const gridTexture = createGroundTexture(gridData, GRID_COLS, GRID_ROWS);
|
||||
let candidates = createGroundStorage(1);
|
||||
const scratch = new Float64Array(12),
|
||||
bounds = new Float64Array(4);
|
||||
const lastView = new Float64Array(16).fill(NaN),
|
||||
lastProjection = new Float64Array(16).fill(NaN);
|
||||
let lastWidth = 0,
|
||||
lastHeight = 0;
|
||||
frames.forEach((frame, index) => {
|
||||
const offset = index * 16,
|
||||
color = cells[index].color;
|
||||
packed.data.set(frame.bounds.longitude, offset);
|
||||
packed.data.set(frame.bounds.south, offset + 4);
|
||||
packed.data.set(frame.bounds.north, offset + 8);
|
||||
packed.data.set(
|
||||
[color.red, color.green, color.blue, color.alpha],
|
||||
offset + 12,
|
||||
);
|
||||
});
|
||||
return {
|
||||
count: cells.length,
|
||||
packed,
|
||||
gridTexture,
|
||||
get candidates() {
|
||||
return candidates;
|
||||
},
|
||||
update(camera, context) {
|
||||
const input = context.getInputBuffer(),
|
||||
view = camera.matrixWorldInverse.elements,
|
||||
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];
|
||||
if (!changed) return;
|
||||
lastView.set(view);
|
||||
lastProjection.set(projection);
|
||||
lastWidth = input.width;
|
||||
lastHeight = input.height;
|
||||
frames.forEach((frame, index) => {
|
||||
viewFrame(frame, camera.matrixWorldInverse, scratch);
|
||||
groundClipBounds(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, index * 4);
|
||||
});
|
||||
const total = grid.pack();
|
||||
if (candidates.data.length / 4 < total) {
|
||||
candidates.texture.dispose();
|
||||
candidates = createGroundStorage(
|
||||
Math.max(total, candidates.data.length / 2),
|
||||
);
|
||||
}
|
||||
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];
|
||||
}
|
||||
gridTexture.needsUpdate = true;
|
||||
candidates.texture.needsUpdate = true;
|
||||
},
|
||||
dispose() {
|
||||
packed.texture.dispose();
|
||||
gridTexture.dispose();
|
||||
candidates.texture.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
function groundEffectClass(EffectDesc, NavaraEffect) {
|
||||
return class FirmsGroundEffectDesc extends EffectDesc {
|
||||
static key = KEY;
|
||||
static insertAfter = [
|
||||
'gevEarthquakeGround',
|
||||
'gevRecentImagery',
|
||||
'toneMapping',
|
||||
];
|
||||
static insertBefore = [
|
||||
'gevPreSharpenOverlay',
|
||||
'gevSharpen',
|
||||
'smaa',
|
||||
'fxaa',
|
||||
'gevOverlay',
|
||||
'final',
|
||||
];
|
||||
constructor(view, context, config) {
|
||||
super(view, context, config);
|
||||
this.owner = config[KEY].owner;
|
||||
}
|
||||
createPass() {
|
||||
this.effect = new Effect('GevFirmsGround', fragmentShader, {
|
||||
attributes: EffectAttribute.CONVOLUTION,
|
||||
uniforms: new Map([
|
||||
...groundSurfaceUniforms(),
|
||||
['tCells', new Uniform(null)],
|
||||
['tGrid', new Uniform(null)],
|
||||
['tCandidates', new Uniform(null)],
|
||||
['uCellSize', new Uniform([1, 1])],
|
||||
['uCandidateSize', new Uniform([1, 1])],
|
||||
['uInverseView', new Uniform(new Matrix4())],
|
||||
]),
|
||||
});
|
||||
return new NavaraEffect(this.view.camera.raw, this.effect);
|
||||
}
|
||||
update() {
|
||||
const state = this.owner.state,
|
||||
camera = this.view.camera.raw;
|
||||
if (!this.effect || !state || !camera) return;
|
||||
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
|
||||
state.update(camera, this.ctx);
|
||||
const u = this.effect.uniforms;
|
||||
u.get('tCells').value = state.packed.texture;
|
||||
u.get('uCellSize').value[0] = state.packed.width;
|
||||
u.get('uCellSize').value[1] = state.packed.height;
|
||||
u.get('tGrid').value = state.gridTexture;
|
||||
u.get('tCandidates').value = state.candidates.texture;
|
||||
u.get('uCandidateSize').value[0] = state.candidates.width;
|
||||
u.get('uCandidateSize').value[1] = state.candidates.height;
|
||||
u.get('uInverseView').value.copy(camera.matrixWorld);
|
||||
}
|
||||
onDestroy() {
|
||||
this.effect?.dispose();
|
||||
super.onDestroy();
|
||||
}
|
||||
};
|
||||
}
|
||||
// The SDK allows one active effect per key. Compose enabled datasets in
|
||||
// catalog order so both can be drawn without replacing one another.
|
||||
const views = new WeakMap();
|
||||
export function createGroundCellDisplay(engine, { view, EffectDesc, NavaraEffect }, order) {
|
||||
if (!registeredViews.has(view)) {
|
||||
view.registerEffect(KEY, groundEffectClass(EffectDesc, NavaraEffect));
|
||||
registeredViews.add(view);
|
||||
}
|
||||
let pass = views.get(view);
|
||||
if (!pass) {
|
||||
pass = { inputs: new Set(), owner: { state: buildState([]) }, handle: null };
|
||||
views.set(view, pass);
|
||||
}
|
||||
const input = { cells: [], visible: false, order };
|
||||
pass.inputs.add(input);
|
||||
function sync() {
|
||||
const cells = [...pass.inputs].filter(item => item.visible)
|
||||
.sort((a,b) => a.order - b.order).flatMap(item => item.cells);
|
||||
const previous = pass.owner.state;
|
||||
pass.owner.state = buildState(cells);
|
||||
try {
|
||||
if (!pass.handle && cells.length) {
|
||||
pass.handle = view.addEffect({ [KEY]: { owner: pass.owner }, visible: true });
|
||||
} else {
|
||||
pass.handle?.update({ visible: cells.length > 0 });
|
||||
}
|
||||
} finally {
|
||||
previous.dispose();
|
||||
}
|
||||
engine.render.request('ground-cells');
|
||||
}
|
||||
return {
|
||||
replace(cells) {
|
||||
input.cells = cells;
|
||||
if (input.visible) sync();
|
||||
},
|
||||
setVisible(show) {
|
||||
if (input.visible === show) return;
|
||||
input.visible = show;
|
||||
sync();
|
||||
},
|
||||
destroy() {
|
||||
if (!pass.inputs.delete(input)) return;
|
||||
if (pass.inputs.size) {
|
||||
if (input.visible) sync();
|
||||
} else {
|
||||
pass.handle?.delete();
|
||||
pass.owner.state.dispose();
|
||||
views.delete(view);
|
||||
engine.render.request('ground-cells-destroy');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import fs from 'node:fs/promises';
|
||||
const declarations = [
|
||||
['gevRecentImagery', '../../recentImagery/render/navaraGround.js'],
|
||||
['gevEarthquakeGround', '../../earthquakes/render/navara.js'],
|
||||
['gevFirmsGround', '../../firms/render/navara.js'],
|
||||
['gevFirmsGround', './navaraGroundCells.js'],
|
||||
['gevPreSharpenOverlay', '../../../globe/navara/overlay.js'],
|
||||
];
|
||||
const specs = await Promise.all(
|
||||
|
||||
@@ -499,7 +499,7 @@ export function createGroundFloor({ terrain, signal }) {
|
||||
return h != null ? h : null;
|
||||
}
|
||||
|
||||
/** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */
|
||||
/** Discard measurements from the outgoing scene while keeping the DEM cache. */
|
||||
function clearMeshFloorCells() {
|
||||
_meshCells.clear();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user