From 1930ca247a816ef68e1974642fc720a611ea25f0 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 02:30:52 +0400 Subject: [PATCH] Keep GNSS history across globe changes and share the Navara ground cell pass --- scripts/check-import-directions.mjs | 1 + scripts/package-boundaries.json | 7 +- ...-switch.mjs => qa-ground-layer-switch.mjs} | 25 +- src/app/constructCatalog.js | 4 +- src/app/layers/gnss.js | 9 +- src/app/layers/renderers.js | 2 + src/app/layers/renderers.test.mjs | 1 + src/app/navaraLayers.test.mjs | 2 + src/app/navaraMode.js | 4 + src/layers/firms/render/navara.js | 297 +--------------- src/layers/firms/render/navara.test.mjs | 27 ++ .../firms/render/navaraGeometry.test.mjs | 2 +- src/layers/gnss/index.js | 169 +++------ src/layers/gnss/ownership.test.mjs | 21 ++ src/layers/gnss/render/cesium.js | 102 ++++++ src/layers/gnss/render/navara.js | 47 +++ .../render/navaraCellGeometry.js} | 0 src/layers/shared/render/navaraGroundCells.js | 320 ++++++++++++++++++ .../shared/render/navaraGroundOrder.test.mjs | 2 +- src/services/groundFloor.js | 2 +- 20 files changed, 629 insertions(+), 415 deletions(-) rename scripts/{qa-air-quality-switch.mjs => qa-ground-layer-switch.mjs} (77%) create mode 100644 src/layers/gnss/render/cesium.js create mode 100644 src/layers/gnss/render/navara.js rename src/layers/{firms/render/navaraGeometry.js => shared/render/navaraCellGeometry.js} (100%) create mode 100644 src/layers/shared/render/navaraGroundCells.js diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 8407c1708..dce7525ca 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -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/', diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f582e4b0a..cbd903b1a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-air-quality-switch.mjs b/scripts/qa-ground-layer-switch.mjs similarity index 77% rename from scripts/qa-air-quality-switch.mjs rename to scripts/qa-ground-layer-switch.mjs index 9d0e39f41..9f8110877 100644 --- a/scripts/qa-air-quality-switch.mjs +++ b/scripts/qa-ground-layer-switch.mjs @@ -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 { diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index c5c1ad229..9a8309847 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -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', diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js index 46983953b..7bb6443e7 100644 --- a/src/app/layers/gnss.js +++ b/src/app/layers/gnss.js @@ -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, + }); } diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index b31a6c0d4..e429dc50d 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -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, diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index 0ba0facff..c2f5d892d 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -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', diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index e6a5cb33d..7b6c199cc 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -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', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 592ff11eb..c77aa1f40 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -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'); diff --git a/src/layers/firms/render/navara.js b/src/layers/firms/render/navara.js index cc8c03f29..b12af4023 100644 --- a/src/layers/firms/render/navara.js +++ b/src/layers/firms/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 { - 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'); }, diff --git a/src/layers/firms/render/navara.test.mjs b/src/layers/firms/render/navara.test.mjs index c6bf2b40c..a127bf9c9 100644 --- a/src/layers/firms/render/navara.test.mjs +++ b/src/layers/firms/render/navara.test.mjs @@ -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; diff --git a/src/layers/firms/render/navaraGeometry.test.mjs b/src/layers/firms/render/navaraGeometry.test.mjs index 13a8817ef..57e4e38b4 100644 --- a/src/layers/firms/render/navaraGeometry.test.mjs +++ b/src/layers/firms/render/navaraGeometry.test.mjs @@ -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]) diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index ba0e9ff5d..37938ce6c 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -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; diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index 713e44889..0ec9abf7c 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -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(); + } +}); diff --git a/src/layers/gnss/render/cesium.js b/src/layers/gnss/render/cesium.js new file mode 100644 index 000000000..dccea9b27 --- /dev/null +++ b/src/layers/gnss/render/cesium.js @@ -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); + }, + }; +} diff --git a/src/layers/gnss/render/navara.js b/src/layers/gnss/render/navara.js new file mode 100644 index 000000000..1ed85349e --- /dev/null +++ b/src/layers/gnss/render/navara.js @@ -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], + ), + })), + ); + }, + }; +} diff --git a/src/layers/firms/render/navaraGeometry.js b/src/layers/shared/render/navaraCellGeometry.js similarity index 100% rename from src/layers/firms/render/navaraGeometry.js rename to src/layers/shared/render/navaraCellGeometry.js diff --git a/src/layers/shared/render/navaraGroundCells.js b/src/layers/shared/render/navaraGroundCells.js new file mode 100644 index 000000000..b6381523c --- /dev/null +++ b/src/layers/shared/render/navaraGroundCells.js @@ -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 { + 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'); + } + }, + }; +} diff --git a/src/layers/shared/render/navaraGroundOrder.test.mjs b/src/layers/shared/render/navaraGroundOrder.test.mjs index e1e60c863..ab4b6d11c 100644 --- a/src/layers/shared/render/navaraGroundOrder.test.mjs +++ b/src/layers/shared/render/navaraGroundOrder.test.mjs @@ -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( diff --git a/src/services/groundFloor.js b/src/services/groundFloor.js index 922103de1..bbd65cdb4 100644 --- a/src/services/groundFloor.js +++ b/src/services/groundFloor.js @@ -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(); }